fix(ui): port retail window control feedback

Map synthetic move and resize affordances to the exact DAT cursors, make chat top chrome movable, and replace stale primary-panel height caps with a dynamic screen-edge constraint. This keeps the retained wrapper adaptation aligned with retail Dragbar/Resizebar behavior.
This commit is contained in:
Erik 2026-07-17 09:40:08 +02:00
parent 3f3cfdac30
commit 06016014bc
16 changed files with 578 additions and 27 deletions

View file

@ -32,17 +32,25 @@ public sealed class CursorFeedbackControllerTests
}
[Theory]
[InlineData(ResizeEdges.Right, CursorFeedbackKind.ResizeHorizontal)]
[InlineData(ResizeEdges.Bottom, CursorFeedbackKind.ResizeVertical)]
[InlineData(ResizeEdges.Right | ResizeEdges.Bottom, CursorFeedbackKind.ResizeDiagonalNwse)]
[InlineData(ResizeEdges.Left | ResizeEdges.Bottom, CursorFeedbackKind.ResizeDiagonalNesw)]
public void ResizeEdges_chooseExpectedCursor(ResizeEdges edges, CursorFeedbackKind expected)
[InlineData(ResizeEdges.Left, CursorFeedbackKind.ResizeHorizontal, 0x06006128u)]
[InlineData(ResizeEdges.Right, CursorFeedbackKind.ResizeHorizontal, 0x06006128u)]
[InlineData(ResizeEdges.Top, CursorFeedbackKind.ResizeVertical, 0x06005E66u)]
[InlineData(ResizeEdges.Bottom, CursorFeedbackKind.ResizeVertical, 0x06005E66u)]
[InlineData(ResizeEdges.Left | ResizeEdges.Top, CursorFeedbackKind.ResizeDiagonalNwse, 0x06006126u)]
[InlineData(ResizeEdges.Right | ResizeEdges.Bottom, CursorFeedbackKind.ResizeDiagonalNwse, 0x06006126u)]
[InlineData(ResizeEdges.Left | ResizeEdges.Bottom, CursorFeedbackKind.ResizeDiagonalNesw, 0x06006127u)]
[InlineData(ResizeEdges.Right | ResizeEdges.Top, CursorFeedbackKind.ResizeDiagonalNesw, 0x06006127u)]
public void ResizeEdges_chooseExpectedRetailCursor(
ResizeEdges edges,
CursorFeedbackKind expected,
uint expectedDid)
{
var c = new CursorFeedbackController();
var feedback = c.Resolve(new CursorFeedbackSnapshot(HoverResizeEdges: edges));
Assert.Equal(expected, feedback.Kind);
Assert.Equal(new UiCursorMedia(expectedDid, 16, 16), feedback.Cursor);
}
[Fact]
@ -52,10 +60,93 @@ public sealed class CursorFeedbackControllerTests
Assert.Equal(CursorFeedbackKind.WindowMove,
c.Resolve(new CursorFeedbackSnapshot(HoverWindowMove: true)).Kind);
Assert.Equal(new UiCursorMedia(0x06006119u, 16, 16),
c.Resolve(new CursorFeedbackSnapshot(HoverWindowMove: true)).Cursor);
Assert.Equal(CursorFeedbackKind.Text,
c.Resolve(new CursorFeedbackSnapshot(HoverTextEdit: true)).Kind);
}
[Fact]
public void ActiveSyntheticResize_keepsControlCursorOverAuthoredCapturedElement()
{
var root = new UiRoot { Width = 400, Height = 300 };
var window = new UiPanel
{
Left = 50,
Top = 40,
Width = 150,
Height = 100,
Draggable = true,
Resizable = true,
};
var content = new UiPanel { Width = 150, Height = 100 };
var authored = new UiCursorMedia(0x06009999u, 3, 4);
content.SetStateCursors(new Dictionary<string, UiCursorMedia> { [""] = authored });
window.AddChild(content);
root.AddChild(window);
var controller = new CursorFeedbackController();
root.OnMouseMove(199, 90); // inside the five-pixel right-edge resizebar region
Assert.Equal(new UiCursorMedia(0x06006128u, 16, 16), controller.Update(root).Cursor);
root.OnMouseDown(UiMouseButton.Left, 199, 90);
root.OnMouseMove(100, 90); // leave the edge while resize capture remains active
CursorFeedback active = controller.Update(root);
Assert.Equal(CursorFeedbackKind.ResizeHorizontal, active.Kind);
Assert.Equal(new UiCursorMedia(0x06006128u, 16, 16), active.Cursor);
}
[Fact]
public void AuthoredWidgetCursor_winsUntilSyntheticWindowMoveStarts()
{
var root = new UiRoot { Width = 400, Height = 300 };
var window = new UiPanel
{
Left = 50,
Top = 40,
Width = 150,
Height = 100,
Draggable = true,
};
var dragRegion = new UiPanel { Width = 150, Height = 100 };
var authored = new UiCursorMedia(0x06008888u, 7, 8);
dragRegion.SetStateCursors(new Dictionary<string, UiCursorMedia> { [""] = authored });
window.AddChild(dragRegion);
root.AddChild(window);
var controller = new CursorFeedbackController();
root.OnMouseMove(100, 90);
Assert.Equal(authored, controller.Update(root).Cursor);
root.OnMouseDown(UiMouseButton.Left, 100, 90);
Assert.Equal(new UiCursorMedia(0x06006119u, 16, 16), controller.Update(root).Cursor);
root.OnMouseMove(300, 200);
Assert.Equal(new UiCursorMedia(0x06006119u, 16, 16), controller.Update(root).Cursor);
}
[Fact]
public void UiLock_suppressesSyntheticWindowControlCursors()
{
var root = new UiRoot { Width = 400, Height = 300, UiLocked = true };
root.AddChild(new UiPanel
{
Left = 50,
Top = 40,
Width = 150,
Height = 100,
Draggable = true,
Resizable = true,
});
root.OnMouseMove(199, 90);
CursorFeedback feedback = new CursorFeedbackController().Update(root);
Assert.Equal(CursorFeedbackKind.Default, feedback.Kind);
Assert.False(feedback.Cursor.IsValid);
}
[Fact]
public void TargetMode_reportsValidInvalidAndPendingTargets()
{

View file

@ -93,6 +93,7 @@ public sealed class RetailWindowFrameTests
ResizeX = false,
ResizeY = true,
ResizableEdges = ResizeEdges.Bottom,
ConstrainResizeToParent = true,
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
ContentClickThrough = true,
});
@ -101,6 +102,7 @@ public sealed class RetailWindowFrameTests
Assert.False(frame.ResizeX);
Assert.True(frame.ResizeY);
Assert.Equal(ResizeEdges.Bottom, frame.ResizableEdges);
Assert.True(frame.ConstrainResizeToParent);
Assert.True(content.ClickThrough);
Assert.False(content.Draggable);
Assert.False(content.Resizable);

View file

@ -1,6 +1,7 @@
using AcDream.App.Rendering;
using AcDream.App.UI;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using SysEnv = System.Environment;
@ -8,6 +9,47 @@ namespace AcDream.App.Tests.UI;
public sealed class RetailCursorCatalogTests
{
[Theory]
[InlineData(CursorFeedbackKind.WindowMove, 0x06006119u)]
[InlineData(CursorFeedbackKind.ResizeHorizontal, 0x06006128u)]
[InlineData(CursorFeedbackKind.ResizeVertical, 0x06005E66u)]
[InlineData(CursorFeedbackKind.ResizeDiagonalNwse, 0x06006126u)]
[InlineData(CursorFeedbackKind.ResizeDiagonalNesw, 0x06006127u)]
public void WindowControlCursorSpecs_matchRetailLayoutDescMedia(
CursorFeedbackKind kind,
uint expectedDid)
{
Assert.True(RetailCursorCatalog.TryGetWindowControlCursor(kind, out var cursor));
Assert.Equal(new UiCursorMedia(expectedDid, 16, 16), cursor);
}
[Fact]
public void WindowControlCursorSpecs_resolveGoldenDatSurfaces()
{
string? datDir = ResolveDatDir();
if (datDir is null)
return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
CursorFeedbackKind[] kinds =
[
CursorFeedbackKind.WindowMove,
CursorFeedbackKind.ResizeHorizontal,
CursorFeedbackKind.ResizeVertical,
CursorFeedbackKind.ResizeDiagonalNwse,
CursorFeedbackKind.ResizeDiagonalNesw,
];
foreach (CursorFeedbackKind kind in kinds)
{
Assert.True(RetailCursorCatalog.TryGetWindowControlCursor(kind, out var cursor));
Assert.True(dats.Portal.TryGet<RenderSurface>(cursor.File, out var surface), kind.ToString());
Assert.NotNull(surface);
Assert.Equal(32, surface.Width);
Assert.Equal(32, surface.Height);
}
}
[Theory]
[InlineData(RetailGlobalCursorKind.Default, 0x01u, 0, 0)]
[InlineData(RetailGlobalCursorKind.DefaultFound, 0x02u, 0, 0)]

View file

@ -118,6 +118,30 @@ public sealed class RetailWindowManagerTests
Assert.Equal(1, resized);
}
[Fact]
public void ProgrammaticResize_ConstrainedToParent_UsesCurrentWindowPosition()
{
var root = new UiRoot { Width = 800, Height = 600 };
var frame = new UiPanel
{
Left = 40,
Top = 125,
Width = 300,
Height = 200,
ResizeX = false,
ResizeY = true,
ConstrainResizeToParent = true,
MinHeight = 100,
};
root.AddChild(frame);
RetailWindowHandle handle = root.RegisterWindow("main-panel", frame);
Assert.True(handle.ResizeTo(frame.Width, 2_000f));
Assert.Equal(475f, frame.Height);
Assert.Equal(root.Height, frame.Top + frame.Height);
}
[Fact]
public void FocusAndCaptureTransitions_AreScopedToOwningHandle()
{

View file

@ -349,6 +349,56 @@ public class UiRootInputTests
root.OnMouseUp(UiMouseButton.Left, 338, 150);
}
[Fact]
public void BottomResize_ConstrainedToParent_ReachesCurrentScreenEdge()
{
var root = new UiRoot { Width = 800, Height = 600 };
var window = new UiPanel
{
Left = 100,
Top = 80,
Width = 300,
Height = 200,
Draggable = true,
Resizable = true,
ResizeX = false,
ResizeY = true,
ResizableEdges = ResizeEdges.Bottom,
ConstrainResizeToParent = true,
MinHeight = 100,
};
root.AddChild(window);
root.OnMouseDown(UiMouseButton.Left, 250, 279);
root.OnMouseMove(250, 2_000);
Assert.Equal(520f, window.Height);
Assert.Equal(root.Height, window.Top + window.Height);
root.OnMouseUp(UiMouseButton.Left, 250, 2_000);
}
[Fact]
public void TopWithoutTopResizeEdge_IsWindowMoveAffordance()
{
var root = new UiRoot { Width = 800, Height = 600 };
var window = new UiPanel
{
Left = 100,
Top = 80,
Width = 300,
Height = 200,
Draggable = true,
Resizable = true,
ResizableEdges = ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Bottom,
};
root.AddChild(window);
root.OnMouseMove(250, 80);
Assert.Equal(ResizeEdges.None, root.HoverResizeEdges);
Assert.True(root.HoverWindowMove);
}
[Fact]
public void ResizeRect_RightBottom_GrowsSizeOnly()
{