Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
The combat bar and spell bar could not be moved at all: their window mounts Draggable=false (correct - retail never whole-surface-drags them) and the authored move mechanism was missing. Retail registers element class 2 as UIElement_Dragbar (Register @ 0x0046C840); a press inside it calls UIElement::StartMovement on its parent window (StartMouseMoving @ 0x0046C760) and release calls StopMovement (@ 0x0046C7C0). The combat/spell bar layout (LayoutDesc 0x21000073) authors exactly one such element - a 600 x 5 strip along the top edge, which is where the user expects the move cursor. The powerbar, vitals, indicators, radar, and examination layouts author dragbars too, so they all gain their retail handles from this one port. Our importer knew Type 2 by name but built it as a generic UiDatElement - ClickThrough decoration, so the strip never even claimed the pointer. Now: - UiElement.WindowMoveHandle marks an authored handle; the DAT factory sets it for Type-2 elements and opts them out of ClickThrough. - A left-press inside a handle subtree moves the handle's top-level window (the outer frame directly under the root - the mounted analogue of retail's dragbar parent) even when that window is not whole-surface Draggable. Edge-resize still wins; UiLocked still gates, matching the retail locked/fixed parent-flag check. - HoverWindowMove reports the handle so the window-move cursor shows over the strip - and only there - on non-Draggable windows. Four new tests: handle press moves a non-Draggable window and stops on release, hover shows the move cursor over the strip but not the body, UiLocked suppresses both, and the factory builds Type 2 as a pointer-claiming move handle. App Release suite 3,966 / 3 skips. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
752 lines
28 KiB
C#
752 lines
28 KiB
C#
using System.Numerics;
|
|
using AcDream.App.UI;
|
|
|
|
namespace AcDream.App.Tests.UI;
|
|
|
|
public class UiRootInputTests
|
|
{
|
|
[Fact]
|
|
public void UiNineSlicePanel_IsNotAnchorManaged_SoUserMoveResizeSticks()
|
|
{
|
|
// Regression: the per-frame anchor pass must NOT reset a window's rect,
|
|
// or move/resize get undone every frame. Windows are user-positioned.
|
|
var panel = new UiNineSlicePanel(_ => ((uint)1, 32, 32));
|
|
Assert.Equal(AnchorEdges.None, panel.Anchors);
|
|
}
|
|
|
|
private sealed class ClickRecorder : UiElement
|
|
{
|
|
private readonly bool _handlesClick;
|
|
public bool Clicked;
|
|
public int Clicks;
|
|
public int DoubleClicks;
|
|
public Action? ClickAction;
|
|
public ClickRecorder(bool handlesClick) => _handlesClick = handlesClick;
|
|
public override bool HandlesClick => _handlesClick;
|
|
public override bool OnEvent(in UiEvent e)
|
|
{
|
|
if (e.Type == UiEventType.Click)
|
|
{
|
|
Clicked = true;
|
|
Clicks++;
|
|
ClickAction?.Invoke();
|
|
return true;
|
|
}
|
|
if (e.Type == UiEventType.DoubleClick) { DoubleClicks++; return true; }
|
|
return false;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void HandlesClickWidget_insideDraggableWindow_stillEmitsClick()
|
|
{
|
|
// Regression (paperdoll "Slots" toggle): a HandlesClick widget (e.g. a UiButton) inside a
|
|
// whole-window-Draggable frame (the inventory window, IA-12 whole-window-drag) must still
|
|
// receive its Click. Before the fix the window-move branch captured the press and OnMouseUp
|
|
// returned before emitting Click, so the toggle button did nothing.
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var frame = new UiPanel { Left = 10, Top = 300, Width = 200, Height = 60, Draggable = true };
|
|
var btn = new ClickRecorder(handlesClick: true) { Left = 5, Top = 5, Width = 120, Height = 14 };
|
|
frame.AddChild(btn);
|
|
root.AddChild(frame);
|
|
|
|
root.OnMouseDown(UiMouseButton.Left, 20, 310); // press over the button (screen rect 15,305..135,319)
|
|
root.OnMouseUp(UiMouseButton.Left, 20, 310); // release same spot → Click
|
|
Assert.True(btn.Clicked);
|
|
}
|
|
|
|
[Fact]
|
|
public void DoubleClick_EmitsRealDoubleClickEvent()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var btn = new ClickRecorder(handlesClick: true) { Left = 5, Top = 5, Width = 120, Height = 14 };
|
|
root.AddChild(btn);
|
|
|
|
root.Tick(0, nowMs: 1_000);
|
|
root.OnMouseDown(UiMouseButton.Left, 20, 10);
|
|
root.OnMouseUp(UiMouseButton.Left, 20, 10);
|
|
root.Tick(0, nowMs: 1_300);
|
|
root.OnMouseDown(UiMouseButton.Left, 20, 10);
|
|
root.OnMouseUp(UiMouseButton.Left, 20, 10);
|
|
|
|
Assert.Equal(2, btn.Clicks);
|
|
Assert.Equal(1, btn.DoubleClicks);
|
|
}
|
|
|
|
[Fact]
|
|
public void DoubleClick_WhenClickHidesCapturedWindow_KeepsOriginalEventTarget()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var window = new UiPanel { Width = 200, Height = 100 };
|
|
var btn = new ClickRecorder(handlesClick: true) { Width = 120, Height = 20 };
|
|
window.AddChild(btn);
|
|
root.AddChild(window);
|
|
root.RegisterWindow("test", window);
|
|
|
|
root.Tick(0, nowMs: 1_000);
|
|
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
|
root.OnMouseUp(UiMouseButton.Left, 10, 10);
|
|
|
|
btn.ClickAction = () => root.HideWindow("test");
|
|
root.Tick(0, nowMs: 1_300);
|
|
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
|
root.OnMouseUp(UiMouseButton.Left, 10, 10);
|
|
|
|
Assert.False(window.Visible);
|
|
Assert.Null(root.Captured);
|
|
Assert.Equal(2, btn.Clicks);
|
|
Assert.Equal(1, btn.DoubleClicks);
|
|
}
|
|
|
|
[Fact]
|
|
public void MouseUp_WhenClickTransfersCapture_PreservesNewCaptureOwner()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var btn = new ClickRecorder(handlesClick: true) { Width = 120, Height = 20 };
|
|
var modal = new UiPanel { Left = 200, Width = 100, Height = 100 };
|
|
root.AddChild(btn);
|
|
root.AddChild(modal);
|
|
btn.ClickAction = () => root.SetCapture(modal);
|
|
|
|
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
|
root.OnMouseUp(UiMouseButton.Left, 10, 10);
|
|
|
|
Assert.Same(modal, root.Captured);
|
|
}
|
|
|
|
[Fact]
|
|
public void ItemDragReleasedOutsideUi_raisesOutsideReleaseEvent()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var cell = new UiItemSlot { Left = 0, Top = 0, Width = 32, Height = 32 };
|
|
cell.SetItem(0x50000A01u, 0x99u);
|
|
root.AddChild(cell);
|
|
object? payload = null;
|
|
int releaseX = 0;
|
|
int releaseY = 0;
|
|
root.DragReleasedOutsideUi += (p, x, y) =>
|
|
{
|
|
payload = p;
|
|
releaseX = x;
|
|
releaseY = y;
|
|
};
|
|
|
|
root.OnMouseDown(UiMouseButton.Left, 5, 5);
|
|
root.OnMouseMove(20, 20);
|
|
root.OnMouseUp(UiMouseButton.Left, 200, 200);
|
|
|
|
var drag = Assert.IsType<ItemDragPayload>(payload);
|
|
Assert.Equal(0x50000A01u, drag.ObjId);
|
|
Assert.Equal(200, releaseX);
|
|
Assert.Equal(200, releaseY);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlainWidget_insideDraggableWindow_doesNotEmitClick()
|
|
{
|
|
// Contrast that locks the distinction: a NON-HandlesClick child inside the Draggable frame is
|
|
// captured as a whole-window-drag, so no Click is emitted (the frame is draggable by it).
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var frame = new UiPanel { Left = 10, Top = 300, Width = 200, Height = 60, Draggable = true };
|
|
var plain = new ClickRecorder(handlesClick: false) { Left = 5, Top = 5, Width = 120, Height = 14 };
|
|
frame.AddChild(plain);
|
|
root.AddChild(frame);
|
|
|
|
root.OnMouseDown(UiMouseButton.Left, 20, 310);
|
|
root.OnMouseUp(UiMouseButton.Left, 20, 310);
|
|
Assert.False(plain.Clicked);
|
|
}
|
|
|
|
private sealed class CoordRecorder : UiElement
|
|
{
|
|
public (int x, int y)? Down, Move;
|
|
public CoordRecorder() { CapturesPointerDrag = true; }
|
|
public override bool OnEvent(in UiEvent e)
|
|
{
|
|
if (e.Type == UiEventType.MouseDown) { Down = (e.Data1, e.Data2); return true; }
|
|
if (e.Type == UiEventType.MouseMove) { Move = (e.Data1, e.Data2); return true; }
|
|
return false;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void MouseDown_And_MouseMove_DeliverSameTargetLocalFrame_ForNestedChild()
|
|
{
|
|
// Regression (adversarial review): a nested child must receive target-LOCAL
|
|
// coords on MouseDown AND MouseMove for the same physical point — otherwise
|
|
// drag-select anchors ~(child offset) px off from where you click. Before the
|
|
// fix MouseDown used HitTestTopDown's window-relative coords (50,40) while
|
|
// MouseMove used target-local (42,32).
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var panel = new UiPanel { Left = 50, Top = 60, Width = 200, Height = 100 };
|
|
var child = new CoordRecorder { Left = 8, Top = 8, Width = 150, Height = 80 };
|
|
panel.AddChild(child);
|
|
root.AddChild(panel);
|
|
|
|
// child ScreenPosition = (58,68). Click screen (100,100) -> local (42,32).
|
|
root.OnMouseDown(UiMouseButton.Left, 100, 100);
|
|
Assert.Equal((42, 32), child.Down);
|
|
|
|
// drag to (120,110) -> local (62,42); MUST share the MouseDown frame.
|
|
root.OnMouseMove(120, 110);
|
|
Assert.Equal((62, 42), child.Move);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplyAnchor_None_IsNoOp()
|
|
{
|
|
var e = new UiPanel { Left = 50, Top = 60, Width = 100, Height = 40, Anchors = AnchorEdges.None };
|
|
e.ApplyAnchor(800, 600);
|
|
Assert.Equal(50f, e.Left);
|
|
Assert.Equal(60f, e.Top);
|
|
Assert.Equal(100f, e.Width);
|
|
Assert.Equal(40f, e.Height);
|
|
}
|
|
|
|
[Fact]
|
|
public void WantsMouse_TrueOverWidget_FalseOverEmptySpace()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var panel = new UiPanel { Left = 10, Top = 10, Width = 100, Height = 50 };
|
|
root.AddChild(panel);
|
|
|
|
root.OnMouseMove(50, 30); // inside the panel
|
|
Assert.True(root.WantsMouse);
|
|
|
|
root.OnMouseMove(500, 400); // empty space
|
|
Assert.False(root.WantsMouse);
|
|
}
|
|
|
|
[Fact]
|
|
public void WindowDrag_RepositionsDraggablePanel_StopsOnRelease()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var panel = new UiPanel { Left = 10, Top = 10, Width = 100, Height = 50, Draggable = true };
|
|
root.AddChild(panel);
|
|
|
|
root.OnMouseDown(UiMouseButton.Left, 20, 20); // grab at (10,10) into the panel
|
|
root.OnMouseMove(120, 90); // drag
|
|
Assert.Equal(110f, panel.Left); // 120 - 10
|
|
Assert.Equal(80f, panel.Top); // 90 - 10
|
|
|
|
root.OnMouseUp(UiMouseButton.Left, 120, 90);
|
|
root.OnMouseMove(300, 300); // released — must not move
|
|
Assert.Equal(110f, panel.Left);
|
|
Assert.Equal(80f, panel.Top);
|
|
}
|
|
|
|
[Fact]
|
|
public void DragHandle_MovesNonDraggableWindow_AndStopsOnRelease()
|
|
{
|
|
// Retail UIElement_Dragbar (element class 2, Register @ 0x0046C840): a press on
|
|
// the authored handle moves a window that is NOT whole-surface Draggable
|
|
// (StartMouseMoving @ 0x0046C760 → UIElement::StartMovement on the parent).
|
|
// The combat/spell bar (LayoutDesc 0x21000073) authors a 600x5 strip at Y=0 and
|
|
// mounts Draggable=false; before the fix the strip was inert and the bars
|
|
// could not be moved at all.
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var window = new UiPanel { Left = 100, Top = 500, Width = 610, Height = 90 };
|
|
var handle = new UiPanel
|
|
{
|
|
Left = 5, Top = 0, Width = 600, Height = 5,
|
|
WindowMoveHandle = true,
|
|
};
|
|
window.AddChild(handle);
|
|
root.AddChild(window);
|
|
|
|
root.OnMouseDown(UiMouseButton.Left, 110, 502); // press inside the top strip
|
|
Assert.True(root.IsWindowMoveActive);
|
|
root.OnMouseMove(160, 452);
|
|
Assert.Equal(150f, window.Left);
|
|
Assert.Equal(450f, window.Top);
|
|
|
|
root.OnMouseUp(UiMouseButton.Left, 160, 452);
|
|
root.OnMouseMove(300, 300); // released — must not move
|
|
Assert.Equal(150f, window.Left);
|
|
Assert.Equal(450f, window.Top);
|
|
}
|
|
|
|
[Fact]
|
|
public void DragHandle_Hover_ShowsMoveCursor_WindowBodyDoesNot()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var window = new UiPanel { Left = 100, Top = 500, Width = 610, Height = 90 };
|
|
var handle = new UiPanel
|
|
{
|
|
Left = 5, Top = 0, Width = 600, Height = 5,
|
|
WindowMoveHandle = true,
|
|
};
|
|
window.AddChild(handle);
|
|
root.AddChild(window);
|
|
|
|
root.OnMouseMove(110, 502); // over the authored strip
|
|
Assert.True(root.HoverWindowMove);
|
|
|
|
root.OnMouseMove(110, 550); // over the body of the non-Draggable window
|
|
Assert.False(root.HoverWindowMove);
|
|
}
|
|
|
|
[Fact]
|
|
public void DragHandle_UiLocked_NeitherMovesNorShowsCursor()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var window = new UiPanel { Left = 100, Top = 500, Width = 610, Height = 90 };
|
|
var handle = new UiPanel
|
|
{
|
|
Left = 5, Top = 0, Width = 600, Height = 5,
|
|
WindowMoveHandle = true,
|
|
};
|
|
window.AddChild(handle);
|
|
root.AddChild(window);
|
|
root.UiLocked = true;
|
|
|
|
root.OnMouseMove(110, 502);
|
|
Assert.False(root.HoverWindowMove);
|
|
|
|
root.OnMouseDown(UiMouseButton.Left, 110, 502);
|
|
root.OnMouseMove(160, 452);
|
|
Assert.Equal(100f, window.Left);
|
|
Assert.Equal(500f, window.Top);
|
|
}
|
|
|
|
[Fact]
|
|
public void WindowDrag_ConstrainedPanel_StaysFullyInsideParent()
|
|
{
|
|
var root = new UiRoot { Width = 200, Height = 150 };
|
|
var panel = new UiPanel
|
|
{
|
|
Left = 10,
|
|
Top = 10,
|
|
Width = 120,
|
|
Height = 100,
|
|
Draggable = true,
|
|
ConstrainDragToParent = true,
|
|
};
|
|
root.AddChild(panel);
|
|
root.RegisterWindow("radar", panel);
|
|
(string name, UiElement window)? moved = null;
|
|
root.WindowMoved += (name, window) => moved = (name, window);
|
|
|
|
root.OnMouseDown(UiMouseButton.Left, 20, 20);
|
|
root.OnMouseMove(500, 500);
|
|
Assert.Equal(80f, panel.Left);
|
|
Assert.Equal(50f, panel.Top);
|
|
|
|
root.OnMouseMove(-500, -500);
|
|
Assert.Equal(0f, panel.Left);
|
|
Assert.Equal(0f, panel.Top);
|
|
root.OnMouseUp(UiMouseButton.Left, -500, -500);
|
|
Assert.Equal("radar", moved?.name);
|
|
Assert.Same(panel, moved?.window);
|
|
}
|
|
|
|
[Fact]
|
|
public void UiLocked_BlocksWindowMoveWithoutDisablingWindow()
|
|
{
|
|
var root = new UiRoot { Width = 200, Height = 150, UiLocked = true };
|
|
var panel = new UiPanel
|
|
{
|
|
Left = 10,
|
|
Top = 10,
|
|
Width = 100,
|
|
Height = 60,
|
|
Draggable = true,
|
|
};
|
|
root.AddChild(panel);
|
|
|
|
root.OnMouseDown(UiMouseButton.Left, 20, 20);
|
|
root.OnMouseMove(100, 100);
|
|
|
|
Assert.Equal(10f, panel.Left);
|
|
Assert.Equal(10f, panel.Top);
|
|
Assert.True(panel.Enabled);
|
|
}
|
|
|
|
[Fact]
|
|
public void NonDraggablePanel_DoesNotMoveOnDrag()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var panel = new UiPanel { Left = 10, Top = 10, Width = 100, Height = 50 }; // Draggable defaults false
|
|
root.AddChild(panel);
|
|
|
|
root.OnMouseDown(UiMouseButton.Left, 20, 20);
|
|
root.OnMouseMove(120, 90);
|
|
Assert.Equal(10f, panel.Left);
|
|
Assert.Equal(10f, panel.Top);
|
|
}
|
|
|
|
[Fact]
|
|
public void CapturesPointerDragChild_DoesNotMoveDraggableAncestor_OnInteriorDrag()
|
|
{
|
|
// A child that captures pointer drags (text selection) must NOT move its
|
|
// draggable ancestor window when the user drags inside it.
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var window = new UiPanel { Left = 10, Top = 10, Width = 200, Height = 100, Draggable = true };
|
|
var child = new UiPanel { Left = 20, Top = 20, Width = 120, Height = 60, CapturesPointerDrag = true };
|
|
window.AddChild(child);
|
|
root.AddChild(window);
|
|
|
|
// Press deep inside the child, then drag.
|
|
root.OnMouseDown(UiMouseButton.Left, 60, 60);
|
|
root.OnMouseMove(160, 160);
|
|
|
|
// Window stays put; the captured child receives the drag itself.
|
|
Assert.Equal(10f, window.Left);
|
|
Assert.Equal(10f, window.Top);
|
|
Assert.Same(child, root.Captured);
|
|
|
|
root.OnMouseUp(UiMouseButton.Left, 160, 160);
|
|
Assert.Equal(10f, window.Left);
|
|
Assert.Equal(10f, window.Top);
|
|
}
|
|
|
|
[Fact]
|
|
public void CapturesPointerDragChild_StillAllowsEdgeResizeOfResizableWindow()
|
|
{
|
|
// Edge resize must still win even when a CapturesPointerDrag child covers
|
|
// the frame: a resizable chat window can be resized from its border.
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var window = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100,
|
|
Draggable = true, Resizable = true, MinWidth = 40, MinHeight = 40 };
|
|
// Child fills the whole window (anchored) and captures interior drags.
|
|
var child = new UiPanel { Left = 0, Top = 0, Width = 200, Height = 100,
|
|
CapturesPointerDrag = true,
|
|
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom };
|
|
window.AddChild(child);
|
|
root.AddChild(window);
|
|
|
|
// Grab within ResizeGrip(5) of the right edge (x=298 of right edge x=300) → resize.
|
|
root.OnMouseDown(UiMouseButton.Left, 298, 150);
|
|
root.OnMouseMove(338, 150);
|
|
Assert.Equal(240f, window.Width);
|
|
Assert.Equal(100f, window.Left);
|
|
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()
|
|
{
|
|
var (x, y, w, h) = UiRoot.ResizeRect(10, 20, 100, 50,
|
|
ResizeEdges.Right | ResizeEdges.Bottom, dx: 30, dy: 15, minW: 40, minH: 40, maxW: float.MaxValue, maxH: float.MaxValue);
|
|
Assert.Equal(10f, x); Assert.Equal(20f, y);
|
|
Assert.Equal(130f, w); Assert.Equal(65f, h);
|
|
}
|
|
|
|
[Fact]
|
|
public void ResizeRect_LeftTop_MovesOriginAndClampsToMin()
|
|
{
|
|
// Drag left edge right by 80 on a 100-wide / min-40 window: width clamps to 40,
|
|
// origin shifts so the RIGHT edge (110) stays put → x = 70.
|
|
var (x, _, w, _) = UiRoot.ResizeRect(10, 20, 100, 50,
|
|
ResizeEdges.Left, dx: 80, dy: 0, minW: 40, minH: 40, maxW: float.MaxValue, maxH: float.MaxValue);
|
|
Assert.Equal(40f, w);
|
|
Assert.Equal(70f, x);
|
|
}
|
|
|
|
[Fact]
|
|
public void ResizeRect_Bottom_ClampsToMaxH()
|
|
{
|
|
// dy=1000 on a 50-tall window with maxH=128 → height clamps to 128, origin unchanged.
|
|
var (_, y, _, h) = UiRoot.ResizeRect(10, 20, 100, 50,
|
|
ResizeEdges.Bottom, dx: 0, dy: 1000, minW: 40, minH: 40, maxW: float.MaxValue, maxH: 128f);
|
|
Assert.Equal(128f, h);
|
|
Assert.Equal(20f, y);
|
|
}
|
|
|
|
[Fact]
|
|
public void ResizeRect_Top_ClampsToMaxH()
|
|
{
|
|
// Drag the top edge UP by 1000 on a 50-tall window with maxH=128 → height clamps to 128,
|
|
// origin shifts so the bottom edge (70) stays put → y = 20 + 50 - 128.
|
|
var (_, y, _, h) = UiRoot.ResizeRect(10, 20, 100, 50,
|
|
ResizeEdges.Top, dx: 0, dy: -1000, minW: 40, minH: 40, maxW: float.MaxValue, maxH: 128f);
|
|
Assert.Equal(128f, h);
|
|
Assert.Equal(20f + 50f - 128f, y);
|
|
}
|
|
|
|
[Fact]
|
|
public void HitEdges_DetectsCornerAndInteriorNone()
|
|
{
|
|
var panel = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100 };
|
|
// bottom-right corner (300,200)
|
|
Assert.Equal(ResizeEdges.Right | ResizeEdges.Bottom, UiRoot.HitEdges(panel, 300, 200, 5));
|
|
// deep interior → no edges
|
|
Assert.Equal(ResizeEdges.None, UiRoot.HitEdges(panel, 200, 150, 5));
|
|
}
|
|
|
|
[Fact]
|
|
public void EdgeDrag_ResizesPanel_InteriorDragMoves()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var panel = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100,
|
|
Draggable = true, Resizable = true, MinWidth = 40, MinHeight = 40 };
|
|
root.AddChild(panel);
|
|
|
|
// grab just inside the right edge (x=298, within ResizeGrip=5 of x=300) and drag right → wider, same origin
|
|
root.OnMouseDown(UiMouseButton.Left, 298, 150);
|
|
root.OnMouseMove(338, 150);
|
|
Assert.Equal(240f, panel.Width);
|
|
Assert.Equal(100f, panel.Left);
|
|
root.OnMouseUp(UiMouseButton.Left, 338, 150);
|
|
|
|
// grab the interior and drag → moves
|
|
root.OnMouseDown(UiMouseButton.Left, 200, 150);
|
|
root.OnMouseMove(220, 170);
|
|
Assert.Equal(120f, panel.Left);
|
|
Assert.Equal(120f, panel.Top);
|
|
root.OnMouseUp(UiMouseButton.Left, 220, 170);
|
|
}
|
|
|
|
[Fact]
|
|
public void HitEdges_RespectsResizeAxisLock()
|
|
{
|
|
var panel = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100, ResizeY = false };
|
|
// right edge still detected (X allowed)
|
|
Assert.True((UiRoot.HitEdges(panel, 300, 150, 5) & ResizeEdges.Right) != 0);
|
|
// bottom edge masked out (Y locked)
|
|
Assert.True((UiRoot.HitEdges(panel, 200, 200, 5) & ResizeEdges.Bottom) == 0);
|
|
}
|
|
|
|
[Fact]
|
|
public void ComputeAnchoredRect_LeftRight_StretchesWidth()
|
|
{
|
|
// bar at x=8,w=200 in a 220-wide parent (right margin 12). Parent grows to 300.
|
|
var (x, _, w, _) = UiElement.ComputeAnchoredRect(
|
|
AnchorEdges.Left | AnchorEdges.Right | AnchorEdges.Top,
|
|
mL: 8, mT: 24, mR: 12, mB: 58, w0: 200, h0: 14, parentW: 300, parentH: 96);
|
|
Assert.Equal(8f, x);
|
|
Assert.Equal(280f, w); // 300 - 12 - 8
|
|
}
|
|
|
|
[Fact]
|
|
public void ComputeAnchoredRect_LeftTopOnly_KeepsFixedSizeAndOrigin()
|
|
{
|
|
var (x, y, w, h) = UiElement.ComputeAnchoredRect(
|
|
AnchorEdges.Left | AnchorEdges.Top,
|
|
mL: 8, mT: 24, mR: 12, mB: 58, w0: 200, h0: 14, parentW: 300, parentH: 96);
|
|
Assert.Equal(8f, x); Assert.Equal(24f, y);
|
|
Assert.Equal(200f, w); Assert.Equal(14f, h);
|
|
}
|
|
|
|
[Fact]
|
|
public void ToggleWindow_FlipsVisible_AndReturnsNewState()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var win = new UiPanel { Width = 100, Height = 100, Visible = false };
|
|
root.AddChild(win);
|
|
root.RegisterWindow("inventory", win);
|
|
|
|
Assert.True(root.ToggleWindow("inventory")); // hidden -> shown
|
|
Assert.True(win.Visible);
|
|
Assert.False(root.ToggleWindow("inventory")); // shown -> hidden
|
|
Assert.False(win.Visible);
|
|
}
|
|
|
|
[Fact]
|
|
public void ShowHideWindow_SetVisibility_UnknownNameIsNoOp()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var win = new UiPanel { Width = 100, Height = 100, Visible = false };
|
|
root.AddChild(win);
|
|
root.RegisterWindow("inventory", win);
|
|
|
|
Assert.False(root.IsWindowVisible("inventory"));
|
|
Assert.True(root.ShowWindow("inventory"));
|
|
Assert.True(win.Visible);
|
|
Assert.True(root.IsWindowVisible("inventory"));
|
|
Assert.True(root.HideWindow("inventory"));
|
|
Assert.False(win.Visible);
|
|
Assert.False(root.IsWindowVisible("inventory"));
|
|
|
|
Assert.False(root.ShowWindow("nope"));
|
|
Assert.False(root.HideWindow("nope"));
|
|
Assert.False(root.ToggleWindow("nope"));
|
|
Assert.False(root.IsWindowVisible("nope"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ShowWindow_RaisesAbovePeers()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var a = new UiPanel { Width = 100, Height = 100 };
|
|
var b = new UiPanel { Width = 100, Height = 100 };
|
|
var win = new UiPanel { Width = 100, Height = 100, Visible = false };
|
|
root.AddChild(a); root.AddChild(b); root.AddChild(win);
|
|
root.RegisterWindow("inventory", win);
|
|
|
|
root.ShowWindow("inventory");
|
|
Assert.True(win.ZOrder > a.ZOrder);
|
|
Assert.True(win.ZOrder > b.ZOrder);
|
|
}
|
|
|
|
[Fact]
|
|
public void BringToFront_SetsStrictlyGreatestZOrder()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var a = new UiPanel { Width = 100, Height = 100, ZOrder = 5 };
|
|
var b = new UiPanel { Width = 100, Height = 100, ZOrder = 9 };
|
|
root.AddChild(a); root.AddChild(b);
|
|
|
|
root.BringToFront(a);
|
|
Assert.True(a.ZOrder > b.ZOrder); // 10 > 9
|
|
}
|
|
|
|
[Fact]
|
|
public void MouseDown_OnWindow_RaisesItAbovePeers()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
// 'peer' has a higher ZOrder but does NOT cover (50,50); 'clicked' does.
|
|
var peer = new UiPanel { Left = 300, Top = 0, Width = 100, Height = 100, ZOrder = 9 };
|
|
var clicked = new UiPanel { Left = 0, Top = 0, Width = 100, Height = 100, ZOrder = 0, Draggable = true };
|
|
root.AddChild(peer); root.AddChild(clicked);
|
|
|
|
root.OnMouseDown(UiMouseButton.Left, 50, 50); // only 'clicked' occupies (50,50)
|
|
Assert.True(clicked.ZOrder > peer.ZOrder);
|
|
root.OnMouseUp(UiMouseButton.Left, 50, 50);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddChild_AssignsEventIdsRecursively()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var parent = new UiPanel { Width = 100, Height = 100 };
|
|
var child = new UiPanel { Width = 50, Height = 50 };
|
|
var grandchild = new UiPanel { Width = 25, Height = 25 };
|
|
child.AddChild(grandchild);
|
|
parent.AddChild(child);
|
|
|
|
root.AddChild(parent);
|
|
|
|
Assert.NotEqual(0u, parent.EventId);
|
|
Assert.NotEqual(0u, child.EventId);
|
|
Assert.NotEqual(0u, grandchild.EventId);
|
|
Assert.Equal(3, new[] { parent.EventId, child.EventId, grandchild.EventId }.Distinct().Count());
|
|
}
|
|
|
|
[Fact]
|
|
public void RemovingSubtree_ClearsAllRootOwnership()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var window = new UiPanel { Width = 200, Height = 100 };
|
|
var field = new UiField { Width = 100, Height = 20 };
|
|
window.AddChild(field);
|
|
root.AddChild(window);
|
|
root.RegisterWindow("test", window);
|
|
root.DefaultTextInput = field;
|
|
root.Modal = window;
|
|
root.SetKeyboardFocus(field);
|
|
root.SetCapture(field);
|
|
|
|
Assert.True(root.RemoveChild(window));
|
|
|
|
Assert.Null(root.KeyboardFocus);
|
|
Assert.Null(root.Captured);
|
|
Assert.Null(root.DefaultTextInput);
|
|
Assert.Null(root.Modal);
|
|
Assert.False(root.ShowWindow("test"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Tick_ChecksTooltipBeforeGlobalTimeMessage_AtRetailDelay()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var recorder = new TimeOrderRecorder { Width = 100, Height = 100 };
|
|
root.AddChild(recorder);
|
|
|
|
root.Tick(0, 1_000);
|
|
root.OnMouseMove(10, 10);
|
|
recorder.Events.Clear();
|
|
root.Tick(0, 1_249);
|
|
Assert.DoesNotContain("tooltip", recorder.Events);
|
|
recorder.Events.Clear();
|
|
|
|
root.Tick(0, 1_250);
|
|
|
|
Assert.Equal(new[] { "tooltip", "global" }, recorder.Events);
|
|
}
|
|
|
|
[Fact]
|
|
public void Tick_GlobalTimeRemoval_SkipsDetachedSiblingWithoutInvalidatingWalk()
|
|
{
|
|
var root = new UiRoot { Width = 800, Height = 600 };
|
|
var victim = new TimeOrderRecorder { Width = 100, Height = 100 };
|
|
var remover = new GlobalTimeAction(() => root.RemoveChild(victim))
|
|
{ Width = 100, Height = 100 };
|
|
root.AddChild(remover);
|
|
root.AddChild(victim);
|
|
|
|
root.Tick(0, 1_000);
|
|
|
|
Assert.Empty(victim.Events);
|
|
Assert.Null(victim.Parent);
|
|
}
|
|
|
|
private sealed class TimeOrderRecorder : UiElement, IUiGlobalTimeListener
|
|
{
|
|
public List<string> Events { get; } = new();
|
|
|
|
public override bool OnEvent(in UiEvent e)
|
|
{
|
|
if (e.Type != UiEventType.Tooltip) return false;
|
|
Events.Add("tooltip");
|
|
return true;
|
|
}
|
|
|
|
public void OnGlobalUiTime(double nowSeconds) => Events.Add("global");
|
|
}
|
|
|
|
private sealed class GlobalTimeAction(Action action) : UiElement, IUiGlobalTimeListener
|
|
{
|
|
public void OnGlobalUiTime(double nowSeconds) => action();
|
|
}
|
|
}
|