feat(ui): port retained widget foundations

This commit is contained in:
Erik 2026-07-10 17:55:41 +02:00
parent 44f9ec13d9
commit d825572e31
44 changed files with 84813 additions and 292 deletions

View file

@ -0,0 +1,63 @@
namespace AcDream.App.UI;
/// <summary>
/// Narrow numeric-state bridge for widgets imported from retail LayoutDesc data.
/// Controllers use retail state ids without knowing how names/media are stored.
/// </summary>
public interface IUiDatStateful
{
uint ActiveRetailStateId { get; }
bool TrySetRetailState(uint stateId);
}
public static class RetailUiStateIds
{
public const uint Closed = 11u;
public const uint Open = 12u;
public const uint HideDetail = 0x10000006u;
public const uint ShowDetail = 0x10000007u;
public const uint ObjectSelected = 0x1000000Bu;
public const uint StackedItemSelected = 0x1000000Cu;
public const uint ItemSlotEmpty = 0x1000001Cu;
public const uint Maximized = 0x10000047u;
public const uint Minimized = 0x10000048u;
public const uint LockedUi = 0x10000063u;
public const uint UnlockedUi = 0x10000064u;
public static string StateName(uint stateId)
=> stateId switch
{
Closed => "Closed",
Open => "Open",
HideDetail => "HideDetail",
ShowDetail => "ShowDetail",
ObjectSelected => "ObjectSelected",
StackedItemSelected => "StackedItemSelected",
ItemSlotEmpty => "ItemSlot_Empty",
Maximized => "Maximized",
Minimized => "Minimized",
LockedUi => "LockedUI",
UnlockedUi => "UnlockedUI",
_ => "",
};
public static bool TryStateId(string stateName, out uint stateId)
{
stateId = stateName switch
{
"Closed" => Closed,
"Open" => Open,
"HideDetail" => HideDetail,
"ShowDetail" => ShowDetail,
"ObjectSelected" => ObjectSelected,
"StackedItemSelected" => StackedItemSelected,
"ItemSlot_Empty" => ItemSlotEmpty,
"Maximized" => Maximized,
"Minimized" => Minimized,
"LockedUI" => LockedUi,
"UnlockedUI" => UnlockedUi,
_ => 0u,
};
return stateId != 0;
}
}

View file

@ -0,0 +1,10 @@
namespace AcDream.App.UI;
/// <summary>
/// Opt-in recipient of retail UI global message 3, broadcast once per UI frame
/// after tooltip deadline processing.
/// </summary>
public interface IUiGlobalTimeListener
{
void OnGlobalUiTime(double nowSeconds);
}

View file

@ -138,8 +138,6 @@ public static class CharacterStatController
// State names as returned by UIStateId.ToString() — used as ActiveState keys on UiButton. // State names as returned by UIStateId.ToString() — used as ActiveState keys on UiButton.
private const string StateOpen = "Open"; // UIStateId.Open = 0x0C — active tab private const string StateOpen = "Open"; // UIStateId.Open = 0x0C — active tab
private const string StateClosed = "Closed"; // UIStateId.Closed = 0x0B — inactive tab private const string StateClosed = "Closed"; // UIStateId.Closed = 0x0B — inactive tab
private const string StateNormal = "Normal"; // UIStateId.Normal = 0x01 — affordable / default
private const string StateGhosted = "Ghosted"; // UIStateId.Ghosted = 0x0D — disabled button
private static readonly Vector4 Body = new(0.92f, 0.90f, 0.82f, 1f); // parchment-white body text private static readonly Vector4 Body = new(0.92f, 0.90f, 0.82f, 1f); // parchment-white body text
private static readonly Vector4 Gold = new(1f, 0.82f, 0.36f, 1f); // section / emphasis gold private static readonly Vector4 Gold = new(1f, 0.82f, 0.36f, 1f); // section / emphasis gold
@ -338,6 +336,7 @@ public static class CharacterStatController
if (datFont is not null) xpValue.DatFont = datFont; if (datFont is not null) xpValue.DatFont = datFont;
xpValue.ClickThrough = true; xpValue.ClickThrough = true;
xpValue.RightAligned = true; xpValue.RightAligned = true;
xpValue.OneLine = true;
xpValue.Padding = 0f; // avoid scroll clip xpValue.Padding = 0f; // avoid scroll clip
xpValue.LinesProvider = () => new[] { new UiText.Line(data().XpToNextLevel.ToString("N0"), Body) }; xpValue.LinesProvider = () => new[] { new UiText.Line(data().XpToNextLevel.ToString("N0"), Body) };
} }
@ -1053,12 +1052,20 @@ public static class CharacterStatController
bool affordable1 = cost1 > 0 && sheet.UnassignedXp >= cost1; bool affordable1 = cost1 > 0 && sheet.UnassignedXp >= cost1;
bool affordable10 = cost10 > 0 && sheet.UnassignedXp >= cost10; bool affordable10 = cost10 > 0 && sheet.UnassignedXp >= cost10;
// State "Normal" = affordable/green; "Ghosted" = too expensive or maxed. foreach (var b in allRaise1)
string btn1State = affordable1 ? StateNormal : StateGhosted; {
string btn10State = affordable10 ? StateNormal : StateGhosted; b.Visible = true;
b.TrySetRetailState(affordable1
foreach (var b in allRaise1) { b.Visible = true; b.ActiveState = btn1State; } ? UiButtonStateMachine.Normal
foreach (var b in allRaise10) { b.Visible = true; b.ActiveState = btn10State; } : UiButtonStateMachine.Ghosted);
}
foreach (var b in allRaise10)
{
b.Visible = true;
b.TrySetRetailState(affordable10
? UiButtonStateMachine.Normal
: UiButtonStateMachine.Ghosted);
}
} }
private static void RefreshSkillRaiseButtons( private static void RefreshSkillRaiseButtons(
@ -1079,12 +1086,12 @@ public static class CharacterStatController
bool affordable = trained bool affordable = trained
? cost > 0 && sheet.UnassignedXp >= cost ? cost > 0 && sheet.UnassignedXp >= cost
: cost > 0 && sheet.SkillCredits >= cost; : cost > 0 && sheet.SkillCredits >= cost;
string state = affordable ? StateNormal : StateGhosted;
foreach (var b in allRaise1) foreach (var b in allRaise1)
{ {
b.Visible = true; b.Visible = true;
b.ActiveState = state; b.TrySetRetailState(affordable
? UiButtonStateMachine.Normal
: UiButtonStateMachine.Ghosted);
} }
foreach (var b in allRaise10) foreach (var b in allRaise10)
@ -1094,7 +1101,9 @@ public static class CharacterStatController
{ {
long cost10 = selectedSkill.Raise10Cost; long cost10 = selectedSkill.Raise10Cost;
bool affordable10 = cost10 > 0 && sheet.UnassignedXp >= cost10; bool affordable10 = cost10 > 0 && sheet.UnassignedXp >= cost10;
b.ActiveState = affordable10 ? StateNormal : StateGhosted; b.TrySetRetailState(affordable10
? UiButtonStateMachine.Normal
: UiButtonStateMachine.Ghosted);
} }
} }
} }
@ -1334,6 +1343,7 @@ public static class CharacterStatController
DatFont = datFont, DatFont = datFont,
ClickThrough = true, ClickThrough = true,
RightAligned = true, RightAligned = true,
OneLine = true,
Anchors = AnchorEdges.Left | AnchorEdges.Top, Anchors = AnchorEdges.Left | AnchorEdges.Top,
}; };
var capturedProvider = valueProvider; var capturedProvider = valueProvider;
@ -1435,6 +1445,7 @@ public static class CharacterStatController
// BackgroundSprite cleared: its full-height sprite would cover line-1/line-2. // BackgroundSprite cleared: its full-height sprite would cover line-1/line-2.
titleEl.BackgroundSprite = 0; titleEl.BackgroundSprite = 0;
titleEl.VerticalJustify = VJustify.Top; // dat says Center; override to Top (see comment above) titleEl.VerticalJustify = VJustify.Top; // dat says Center; override to Top (see comment above)
titleEl.OneLine = true;
} }
// Title (FooterTitle 0x1000024E, 20px from dat): pass null → keep dat font. // Title (FooterTitle 0x1000024E, 20px from dat): pass null → keep dat font.
// Fix C: the dat has a 20px font for the footer title. Let it drive. // Fix C: the dat has a 20px font for the footer title. Let it drive.
@ -1706,6 +1717,7 @@ public static class CharacterStatController
// Non-null = controller explicit override. // Non-null = controller explicit override.
if (datFont is not null) t.DatFont = datFont; if (datFont is not null) t.DatFont = datFont;
t.Centered = true; t.Centered = true;
t.OneLine = true;
t.ClickThrough = true; t.ClickThrough = true;
t.LinesProvider = () => new[] { new UiText.Line(text(), color) }; t.LinesProvider = () => new[] { new UiText.Line(text(), color) };
} }
@ -1829,6 +1841,7 @@ public static class CharacterStatController
DatFont = datFont, DatFont = datFont,
ClickThrough = true, ClickThrough = true,
Centered = true, Centered = true,
OneLine = true,
Anchors = AnchorEdges.Left | AnchorEdges.Top, Anchors = AnchorEdges.Left | AnchorEdges.Top,
}; };
labelEl.LinesProvider = () => new[] { new UiText.Line(capturedLabel, labelColor) }; labelEl.LinesProvider = () => new[] { new UiText.Line(capturedLabel, labelColor) };

View file

@ -16,9 +16,9 @@ namespace AcDream.App.UI.Layout;
/// <para> /// <para>
/// The transcript (<c>0x10000011</c>) is Type-12 and is built as a <see cref="UiText"/> /// The transcript (<c>0x10000011</c>) is Type-12 and is built as a <see cref="UiText"/>
/// by the factory; this controller binds its live data provider in place. The input /// by the factory; this controller binds its live data provider in place. The input
/// (<c>0x10000016</c>) is also Type-12, so the factory builds it as an invisible /// (<c>0x10000016</c>) carries Editable property 0x16, so the factory builds it
/// <see cref="UiText"/> placeholder; this controller removes that placeholder and adds /// directly as <see cref="UiField"/> and this controller binds it in place. The
/// a <see cref="UiField"/> at the same rect. The scrollbar track (<c>0x10000012</c>) is /// scrollbar track (<c>0x10000012</c>) is
/// built directly as a <see cref="UiScrollbar"/> by the factory (Type 11) and bound in /// built directly as a <see cref="UiScrollbar"/> by the factory (Type 11) and bound in
/// place. The channel menu (<c>0x10000014</c>) is built as <see cref="UiMenu"/> (Type 6) /// place. The channel menu (<c>0x10000014</c>) is built as <see cref="UiMenu"/> (Type 6)
/// and bound in place. /// and bound in place.
@ -36,7 +36,7 @@ public sealed class ChatWindowController
private const uint TrackId = 0x10000012u; private const uint TrackId = 0x10000012u;
private const uint InputBarId = 0x10000013u; private const uint InputBarId = 0x10000013u;
private const uint MenuId = 0x10000014u; private const uint MenuId = 0x10000014u;
private const uint InputId = 0x10000016u; // Type-12 Text — factory builds UiText placeholder; Bind removes + replaces with UiField private const uint InputId = 0x10000016u; // Type-12 Text + Editable 0x16 → UiField
private const uint SendId = 0x10000019u; private const uint SendId = 0x10000019u;
private const uint MaxMinId = 0x1000046Fu; private const uint MaxMinId = 0x1000046Fu;
@ -48,9 +48,6 @@ public sealed class ChatWindowController
private const uint UpSprite = 0x06004C6Cu; // up arrow (top button) private const uint UpSprite = 0x06004C6Cu; // up arrow (top button)
private const uint DownSprite = 0x06004C69u; // down arrow (bottom button) private const uint DownSprite = 0x06004C69u; // down arrow (bottom button)
// Chat input focused-field background (element 0x10000016 Normal_focussed state).
private const uint InputFocusField = 0x060011ABu; // gold "lit" field when in write mode
// Channel menu sprite ids (confirmed in chat element dump). // Channel menu sprite ids (confirmed in chat element dump).
private const uint MenuNormal = 0x06004D65u; // button face private const uint MenuNormal = 0x06004D65u; // button face
private const uint MenuPressed = 0x06004D66u; // button pressed private const uint MenuPressed = 0x06004D66u; // button pressed
@ -159,20 +156,16 @@ public sealed class ChatWindowController
BitmapFont? debugFont, BitmapFont? debugFont,
Func<uint, (uint tex, int w, int h)> resolve) Func<uint, (uint tex, int w, int h)> resolve)
{ {
// The transcript is built as a UiText by the factory (Type 12).
// The input node (0x10000016) is also Type-12 → UiText, but the controller replaces
// it with a UiField. Read its rect from the raw ElementInfo tree first.
var iInfo = FindInfo(rootInfo, InputId);
// Their parent panels must exist as real widgets in the layout tree. // Their parent panels must exist as real widgets in the layout tree.
var transcriptPanel = layout.FindElement(TranscriptPanelId); var transcriptPanel = layout.FindElement(TranscriptPanelId);
var inputBar = layout.FindElement(InputBarId); var inputBar = layout.FindElement(InputBarId);
var input = layout.FindElement(InputId) as UiField;
if (iInfo is null || transcriptPanel is null || inputBar is null) if (input is null || transcriptPanel is null || inputBar is null)
{ {
Console.WriteLine( Console.WriteLine(
$"[D.2b] ChatWindowController.Bind: missing required elements " + $"[D.2b] ChatWindowController.Bind: missing required elements " +
$"(iInfo={iInfo is not null}, " + $"(input={input is not null}, " +
$"panel={transcriptPanel is not null}, bar={inputBar is not null}) — " + $"panel={transcriptPanel is not null}, bar={inputBar is not null}) — " +
$"chat window will not be interactive."); $"chat window will not be interactive.");
return null; return null;
@ -215,29 +208,19 @@ public sealed class ChatWindowController
// caches layout for mouse selection and Ctrl+C. // caches layout for mouse selection and Ctrl+C.
c.Transcript.Centered = false; c.Transcript.Centered = false;
c.Transcript.RightAligned = false; c.Transcript.RightAligned = false;
c.Transcript.OneLine = false;
c.Transcript.Selectable = true;
c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript
c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont); c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont);
// ── Input ──────────────────────────────────────────────────────── // ── Input ────────────────────────────────────────────────────────
// The input element (0x10000016) resolves to Type-12 Text, so the factory built it // Editable/selectable/one-line semantics and state sprites came from the
// as an unbound (invisible) UiText placeholder in the input bar. The editable entry // imported property/state bags. The controller supplies runtime services only.
// is a controller-placed UiField at the same rect — drop the placeholder, add the field. c.Input = input;
if (layout.FindElement(InputId) is { Parent: { } inParent } inputPlaceholder) c.Input.DatFont = datFont;
inParent.RemoveChild(inputPlaceholder); c.Input.Font = debugFont;
c.Input = new UiField c.Input.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f);
{ c.Input.SpriteResolve = resolve;
Left = iInfo.X,
Top = iInfo.Y,
Width = iInfo.Width,
Height = iInfo.Height,
Anchors = ElementReader.ToAnchors(iInfo.Left, iInfo.Top, iInfo.Right, iInfo.Bottom),
DatFont = datFont,
Font = debugFont,
BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f), // retail translucent unfocused field
SpriteResolve = resolve,
FocusFieldSprite = InputFocusField,
};
inputBar.AddChild(c.Input);
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel); c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
// ── Scrollbar — bind the factory-built Type-11 track element ──────── // ── Scrollbar — bind the factory-built Type-11 track element ────────
@ -360,21 +343,6 @@ public sealed class ChatWindowController
// ── Helpers ──────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────
/// <summary>
/// Depth-first search for an <see cref="ElementInfo"/> node by id in the
/// raw info tree (which contains ALL elements, including the Type-12 skipped ones).
/// </summary>
private static ElementInfo? FindInfo(ElementInfo node, uint id)
{
if (node.Id == id) return node;
foreach (var child in node.Children)
{
var found = FindInfo(child, id);
if (found is not null) return found;
}
return null;
}
/// <summary> /// <summary>
/// Convert the ChatVM's detailed lines to the transcript's /// Convert the ChatVM's detailed lines to the transcript's
/// <see cref="UiText.Line"/> record format, applying retail-faithful /// <see cref="UiText.Line"/> record format, applying retail-faithful

View file

@ -10,11 +10,10 @@ namespace AcDream.App.UI.Layout;
/// <see cref="UiDatElement"/>. /// <see cref="UiDatElement"/>.
/// ///
/// <para> /// <para>
/// Type 12 = UIElement_Text — a scrollable colored-line text view. Every Type-12 /// Type 12 = UIElement_Text. Editable `0x16` elements become <see cref="UiField"/>
/// element is now built as a <see cref="UiText"/>. Elements that carry their own /// in place; other elements become display/selectable <see cref="UiText"/> widgets.
/// dat sprite media keep it as the <see cref="UiText.BackgroundSprite"/>. Pure /// Elements that carry their own DAT sprite media keep it as widget background art.
/// prototype elements (no state media, no controller binding) draw nothing because /// Pure prototype elements draw nothing because text backgrounds default transparent.
/// <see cref="UiText.BackgroundColor"/> defaults to transparent.
/// </para> /// </para>
/// ///
/// <para> /// <para>
@ -102,9 +101,31 @@ public static class DatWidgetFactory
e.ZOrder = (int)info.ReadOrder - (int)info.ZLevel * 10000; e.ZOrder = (int)info.ReadOrder - (int)info.ZLevel * 10000;
// Map the four raw edge-anchor values to the AnchorEdges bit-flag that the // Map the four raw edge-anchor values to the AnchorEdges bit-flag that the
// UI layout engine uses for reflow. // compatibility layout engine uses for programmatic overrides.
e.Anchors = ElementReader.ToAnchors(info.Left, info.Top, info.Right, info.Bottom); e.Anchors = ElementReader.ToAnchors(info.Left, info.Top, info.Right, info.Bottom);
// Imported descendants use the exact four-mode retail policy. Roots have no
// design parent and intentionally remain on the compatibility path until a
// window mount assigns its own outer-frame policy.
if (info.HasOriginalParentSize)
{
e.LayoutPolicy = new UiLayoutPolicy(
info.Left,
info.Top,
info.Right,
info.Bottom,
UiPixelRect.FromPositionAndSize(
(int)info.X,
(int)info.Y,
(int)info.Width,
(int)info.Height),
UiPixelRect.FromPositionAndSize(
0,
0,
(int)info.OriginalParentWidth,
(int)info.OriginalParentHeight));
}
return e; return e;
} }
@ -253,7 +274,8 @@ public static class DatWidgetFactory
// ── Text ───────────────────────────────────────────────────────────────── // ── Text ─────────────────────────────────────────────────────────────────
/// <summary>Type-12 UIElement_Text: a scrollable colored-line text view. The element's /// <summary>Type-12 UIElement_Text: an editable field or colored-line text view,
/// selected from the canonical property bag. The element's
/// own Direct/Normal media (if any) becomes the background sprite, drawn under the text — /// own Direct/Normal media (if any) becomes the background sprite, drawn under the text —
/// so a Type-12 element that previously rendered via UiDatElement keeps its sprite. Lines /// so a Type-12 element that previously rendered via UiDatElement keeps its sprite. Lines
/// are bound later by the controller (LinesProvider). An unbound UiText draws nothing /// are bound later by the controller (LinesProvider). An unbound UiText draws nothing
@ -273,7 +295,7 @@ public static class DatWidgetFactory
/// font; otherwise it is the shared global fallback. Controllers that call /// font; otherwise it is the shared global fallback. Controllers that call
/// <see cref="ImportedLayout.FindElement"/> and set <see cref="UiText.DatFont"/> afterward /// <see cref="ImportedLayout.FindElement"/> and set <see cref="UiText.DatFont"/> afterward
/// still override this — the build-time value is just the starting point.</param> /// still override this — the build-time value is just the starting point.</param>
private static UiText BuildText(ElementInfo info, Func<uint, (uint, int, int)> resolve, private static UiElement BuildText(ElementInfo info, Func<uint, (uint, int, int)> resolve,
UiDatFont? elementFont = null) UiDatFont? elementFont = null)
{ {
uint bg = info.StateMedia.TryGetValue( uint bg = info.StateMedia.TryGetValue(
@ -281,6 +303,35 @@ public static class DatWidgetFactory
: info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m) : info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m)
? m.File : 0u; ? m.File : 0u;
bool editable = info.TryGetEffectiveBool(0x16u, out var editableValue)
&& editableValue;
bool selectable = info.TryGetEffectiveBool(0x27u, out var selectableValue)
&& selectableValue;
bool oneLine = info.TryGetEffectiveBool(0x20u, out var oneLineValue)
&& oneLineValue;
if (editable)
{
uint focusSprite = info.StateMedia.TryGetValue("Normal_focussed", out var focus)
? focus.File
: 0u;
var field = new UiField
{
ElementId = info.Id,
DatFont = elementFont,
SpriteResolve = resolve,
BackgroundSprite = bg,
FocusFieldSprite = focusSprite,
Selectable = selectable,
OneLine = oneLine,
};
if (info.TryGetEffectiveInteger(0x1Eu, out int maxCharacters))
field.MaxCharacters = maxCharacters;
if (info.FontColor.HasValue)
field.TextColor = info.FontColor.Value;
return field;
}
// Apply horizontal + vertical justification from the dat at build time. // Apply horizontal + vertical justification from the dat at build time.
// Controllers that call FindElement and set Centered/RightAligned/VerticalJustify // Controllers that call FindElement and set Centered/RightAligned/VerticalJustify
// afterward will override these — this is only the dat-driven default. // afterward will override these — this is only the dat-driven default.
@ -301,6 +352,8 @@ public static class DatWidgetFactory
Centered = centered, Centered = centered,
RightAligned = rightAligned, RightAligned = rightAligned,
VerticalJustify = vJustify, VerticalJustify = vJustify,
OneLine = oneLine,
Selectable = selectable,
// Seed the dat-driven font. When a font resolver was supplied and the element // Seed the dat-driven font. When a font resolver was supplied and the element
// carries a non-zero FontDid, elementFont is the element-specific dat font; otherwise // carries a non-zero FontDid, elementFont is the element-specific dat font; otherwise
// it is the shared global fallback. Controllers that call FindElement and explicitly // it is the shared global fallback. Controllers that call FindElement and explicitly

View file

@ -21,8 +21,8 @@ public enum VJustify : byte { Top = 0, Center = 1, Bottom = 2 }
/// after inheritance is applied. The pure transforms on <see cref="ElementReader"/> /// after inheritance is applied. The pure transforms on <see cref="ElementReader"/>
/// operate on this type so they can be unit-tested without the dats or OpenGL. /// operate on this type so they can be unit-tested without the dats or OpenGL.
/// ///
/// IMPORTANT: Tasks 36 depend on this shape exactly. Do not add members without /// This is the canonical importer/factory seam. Add fields only with matching
/// updating the plan spec and downstream consumers. /// inheritance, fixture-serialization, and conformance coverage.
/// </summary> /// </summary>
public sealed class ElementInfo public sealed class ElementInfo
{ {
@ -41,11 +41,20 @@ public sealed class ElementInfo
/// <summary>Position and size within the parent, in pixels (cast from dat uint fields).</summary> /// <summary>Position and size within the parent, in pixels (cast from dat uint fields).</summary>
public float X, Y, Width, Height; public float X, Y, Width, Height;
/// <summary>
/// Design-time parent size used by retail raw-edge reflow. Set by the DAT
/// importer for descendants; imported roots deliberately leave this unset.
/// Mounted base-layout children retain their original base parent size.
/// </summary>
public float OriginalParentWidth, OriginalParentHeight;
public bool HasOriginalParentSize;
/// <summary> /// <summary>
/// Raw edge-anchor flag values from the dat (<c>LeftEdge</c>, <c>TopEdge</c>, /// Raw edge-anchor flag values from the dat (<c>LeftEdge</c>, <c>TopEdge</c>,
/// <c>RightEdge</c>, <c>BottomEdge</c> fields of <c>ElementDesc</c>). /// <c>RightEdge</c>, <c>BottomEdge</c> fields of <c>ElementDesc</c>).
/// Values 04; map to <see cref="AnchorEdges"/> bit-flags via /// Values 04. Imported elements preserve these in <see cref="UiLayoutPolicy"/>;
/// <see cref="ElementReader.ToAnchors"/>. /// <see cref="ElementReader.ToAnchors"/> is only the compatibility projection
/// for older programmatic consumers.
/// </summary> /// </summary>
public uint Left, Top, Right, Bottom; public uint Left, Top, Right, Bottom;
@ -58,6 +67,16 @@ public sealed class ElementInfo
/// within-layer tiebreaker. Issue #145 (vitals are all ZLevel 0, so they're unaffected).</summary> /// within-layer tiebreaker. Issue #145 (vitals are all ZLevel 0, so they're unaffected).</summary>
public uint ZLevel; public uint ZLevel;
/// <summary>
/// Canonical state descriptors keyed by numeric retail state id. The unnamed
/// DirectState uses <see cref="UiStateInfo.DirectStateId"/>. Unlike the legacy
/// projections below, properties remain scoped to the state that defines them.
/// </summary>
public Dictionary<uint, UiStateInfo> States = new();
/// <summary>Numeric form of <see cref="DefaultStateName"/>; zero means unset.</summary>
public uint DefaultStateId;
/// <summary> /// <summary>
/// Font dat object id inherited from the base element's <c>Properties[0x1A]</c> /// Font dat object id inherited from the base element's <c>Properties[0x1A]</c>
/// (<c>ArrayBaseProperty → DataIdBaseProperty</c>). 0 = none / not inherited. /// (<c>ArrayBaseProperty → DataIdBaseProperty</c>). 0 = none / not inherited.
@ -115,6 +134,82 @@ public sealed class ElementInfo
/// Children come from the derived element's own tree, not the base element's. /// Children come from the derived element's own tree, not the base element's.
/// </summary> /// </summary>
public List<ElementInfo> Children = new(); public List<ElementInfo> Children = new();
/// <summary>
/// Resolves a property for a state using retail's DirectState-as-base rule. A
/// named state's key overrides DirectState by presence, including false/zero.
/// When no state is supplied, the element's default, Normal, then DirectState
/// is selected in that order.
/// </summary>
public bool TryGetEffectiveProperty(uint propertyId, out UiPropertyValue value, uint? stateId = null)
{
uint effectiveState = stateId ?? EffectiveDefaultStateId();
UiPropertyValue? resolved = null;
if (States.TryGetValue(UiStateInfo.DirectStateId, out var direct)
&& direct.Properties.TryGetValue(propertyId, out var directValue))
{
resolved = directValue;
}
if (effectiveState != UiStateInfo.DirectStateId
&& States.TryGetValue(effectiveState, out var state)
&& state.Properties.TryGetValue(propertyId, out var stateValue))
{
resolved = stateValue;
}
value = resolved!;
return resolved is not null;
}
public bool TryGetEffectiveBool(uint propertyId, out bool value, uint? stateId = null)
{
if (TryGetEffectiveProperty(propertyId, out var property, stateId)
&& property.Kind == UiPropertyKind.Bool)
{
value = property.BoolValue;
return true;
}
value = false;
return false;
}
public bool TryGetEffectiveInteger(uint propertyId, out int value, uint? stateId = null)
{
if (TryGetEffectiveProperty(propertyId, out var property, stateId)
&& property.Kind == UiPropertyKind.Integer)
{
value = property.IntegerValue;
return true;
}
value = 0;
return false;
}
public bool TryGetEffectiveFloat(uint propertyId, out float value, uint? stateId = null)
{
if (TryGetEffectiveProperty(propertyId, out var property, stateId)
&& property.Kind == UiPropertyKind.Float)
{
value = property.FloatValue;
return true;
}
value = 0f;
return false;
}
public uint EffectiveDefaultStateId()
{
if (DefaultStateId != 0 && States.ContainsKey(DefaultStateId))
return DefaultStateId;
if (States.ContainsKey(1u)) // UIStateId.Normal
return 1u;
return UiStateInfo.DirectStateId;
}
} }
/// <summary> /// <summary>
@ -125,12 +220,10 @@ public sealed class ElementInfo
/// </summary> /// </summary>
public static class ElementReader public static class ElementReader
{ {
/// <summary>Edge-anchor flags → AnchorEdges, per retail UIElement::UpdateForParentSizeChange /// <summary>Compatibility projection from raw retail modes to the legacy
/// @0x00462640. The far-axis fields drive stretch: RightEdge==1 ⇒ the right edge tracks the /// <see cref="AnchorEdges"/> flags. This projection cannot represent centered
/// parent's right edge (stretch); LeftEdge==2 ⇒ a fixed-width element's left tracks the right /// mode 3 or proportional mode 4 exactly. Imported DAT widgets therefore use
/// edge (it moves right). ==4 (not present in the vitals layout) = both-sides stretch; ==3 = /// <see cref="UiLayoutPolicy"/>; call this only for legacy/programmatic paths.</summary>
/// centered (no edge anchor → falls back to pin-top-left). This is the INVERSE of the earlier
/// format-doc §4 reading, which was wrong (it made every piece fixed-width).</summary>
/// <param name="left">LeftEdge dat field value (04).</param> /// <param name="left">LeftEdge dat field value (04).</param>
/// <param name="top">TopEdge dat field value (04).</param> /// <param name="top">TopEdge dat field value (04).</param>
/// <param name="right">RightEdge dat field value (04).</param> /// <param name="right">RightEdge dat field value (04).</param>
@ -180,12 +273,9 @@ public static class ElementReader
var m = new ElementInfo var m = new ElementInfo
{ {
Id = derived.Id != 0 ? derived.Id : base_.Id, Id = derived.Id != 0 ? derived.Id : base_.Id,
// Type: derived wins if non-zero; Type 0 (text element per format §8) inherits the base's Type. // Type: derived wins if non-zero. Layout instances commonly carry Type 0
// For a text element whose base prototype is Type 12 (style prototype), this yields Type 12 — // and inherit the registered widget type (for example Type 12 text) from
// which DatWidgetFactory skips (returns null). That is intentional for Plan 1: vitals text // their base prototype; DatWidgetFactory then builds that behavioral type.
// numbers render via UiMeter.Label bound by VitalsController, not a dat text node.
// A Plan-2 standalone text element would need a type-preserving path (e.g. float? nullable
// Width/Height, or explicit handling of Type 0 before the merge).
Type = derived.Type != 0 ? derived.Type : base_.Type, Type = derived.Type != 0 ? derived.Type : base_.Type,
X = derived.X, X = derived.X,
Y = derived.Y, Y = derived.Y,
@ -193,7 +283,7 @@ public static class ElementReader
// diverges from the format doc §12 rule 2 ("derived W/H win even if zero") but is // diverges from the format doc §12 rule 2 ("derived W/H win even if zero") but is
// indistinguishable for Plan 1 (all base elements are zero-size Type-12 prototypes). // indistinguishable for Plan 1 (all base elements are zero-size Type-12 prototypes).
// If a real zero-size derived element ever needs to override a non-zero base in // If a real zero-size derived element ever needs to override a non-zero base in
// Plan 2, switch Width/Height to float? + null-coalescing (and update Tasks 3-5). // switch Width/Height to nullable values and use presence-aware merging.
Width = derived.Width != 0 ? derived.Width : base_.Width, Width = derived.Width != 0 ? derived.Width : base_.Width,
Height = derived.Height != 0 ? derived.Height : base_.Height, Height = derived.Height != 0 ? derived.Height : base_.Height,
Left = derived.Left, Left = derived.Left,
@ -202,6 +292,7 @@ public static class ElementReader
Bottom = derived.Bottom, Bottom = derived.Bottom,
ReadOrder = derived.ReadOrder, ReadOrder = derived.ReadOrder,
ZLevel = derived.ZLevel != 0 ? derived.ZLevel : base_.ZLevel, ZLevel = derived.ZLevel != 0 ? derived.ZLevel : base_.ZLevel,
DefaultStateId = derived.DefaultStateId != 0 ? derived.DefaultStateId : base_.DefaultStateId,
FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid, FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid,
// HJustify/VJustify: derived wins when it carries an explicit non-Center value // HJustify/VJustify: derived wins when it carries an explicit non-Center value
// (the dat property was present and read); otherwise inherit the base prototype's value. // (the dat property was present and read); otherwise inherit the base prototype's value.
@ -216,7 +307,7 @@ public static class ElementReader
DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName, DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName,
// Children come from the derived element's own tree, not the base prototype's. // Children come from the derived element's own tree, not the base prototype's.
// Defensive copy: prevent a later mutation of either the merged result or the input // Defensive copy: prevent a later mutation of either the merged result or the input
// from corrupting the other. Safe for the Task-5 flow (derived.Children is fully // from corrupting the other. Safe because derived.Children is fully
// populated by the recursive importer BEFORE Merge is called and never mutated after). // populated by the recursive importer BEFORE Merge is called and never mutated after).
Children = new List<ElementInfo>(derived.Children), Children = new List<ElementInfo>(derived.Children),
}; };
@ -227,6 +318,68 @@ public static class ElementReader
m.StateCursors = new Dictionary<string, UiCursorMedia>(base_.StateCursors); m.StateCursors = new Dictionary<string, UiCursorMedia>(base_.StateCursors);
foreach (var kv in derived.StateCursors) foreach (var kv in derived.StateCursors)
m.StateCursors[kv.Key] = kv.Value; m.StateCursors[kv.Key] = kv.Value;
m.States = new Dictionary<uint, UiStateInfo>();
foreach (var (id, state) in base_.States)
m.States[id] = state.Clone();
foreach (var (id, state) in derived.States)
{
m.States[id] = m.States.TryGetValue(id, out var baseState)
? UiStateInfo.Merge(baseState, state)
: state.Clone();
}
ApplyCanonicalLegacyProjection(m);
return m; return m;
} }
internal static void ApplyCanonicalLegacyProjection(ElementInfo info)
{
if (info.TryGetEffectiveProperty(0x1Au, out var font)
&& font.Kind == UiPropertyKind.Array
&& font.ArrayValue.Count > 0
&& font.ArrayValue[0].Kind == UiPropertyKind.DataId)
{
info.FontDid = checked((uint)font.ArrayValue[0].UnsignedValue);
}
if (info.TryGetEffectiveProperty(0x14u, out var horizontal)
&& horizontal.Kind == UiPropertyKind.Enum)
{
info.HJustify = horizontal.UnsignedValue switch
{
0u => HJustify.Left,
3u or 5u => HJustify.Right,
_ => HJustify.Center,
};
}
if (info.TryGetEffectiveProperty(0x15u, out var vertical)
&& vertical.Kind == UiPropertyKind.Enum)
{
info.VJustify = vertical.UnsignedValue switch
{
2u => VJustify.Top,
4u => VJustify.Bottom,
_ => VJustify.Center,
};
}
if (info.TryGetEffectiveProperty(0x1Bu, out var color))
{
UiPropertyValue? colorValue = color.Kind == UiPropertyKind.Color
? color
: color.Kind == UiPropertyKind.Array
&& color.ArrayValue.Count > 0
&& color.ArrayValue[0].Kind == UiPropertyKind.Color
? color.ArrayValue[0]
: null;
if (colorValue is not null)
{
var c = colorValue.ColorValue;
float alpha = c.Alpha == 0 ? 1f : c.Alpha / 255f;
info.FontColor = new Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, alpha);
}
}
}
} }

View file

@ -463,6 +463,7 @@ public sealed class InventoryController : IItemListDragHandler
if (host is UiText t) if (host is UiText t)
{ {
t.Centered = true; t.Centered = true;
t.OneLine = true;
t.DatFont = datFont; t.DatFont = datFont;
t.ClickThrough = true; t.ClickThrough = true;
t.AcceptsFocus = false; t.AcceptsFocus = false;
@ -483,7 +484,7 @@ public sealed class InventoryController : IItemListDragHandler
{ {
Left = 0f, Top = 0f, Width = host.Width, Height = host.Height, Left = 0f, Top = 0f, Width = host.Width, Height = host.Height,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
Centered = true, DatFont = datFont, ClickThrough = true, Centered = true, OneLine = true, DatFont = datFont, ClickThrough = true,
AcceptsFocus = false, IsEditControl = false, CapturesPointerDrag = false, AcceptsFocus = false, IsEditControl = false, CapturesPointerDrag = false,
LinesProvider = () => LinesProvider = () =>
{ {

View file

@ -203,9 +203,19 @@ public static class LayoutImporter
tops.Add(Resolve(dats, d, new HashSet<(uint, uint)>())); tops.Add(Resolve(dats, d, new HashSet<(uint, uint)>()));
} }
return tops.Count == 1 if (tops.Count == 1)
? tops[0] return tops[0];
: new ElementInfo { Id = 0, Type = 3, Children = tops };
foreach (var top in tops)
SetOriginalParentSize(top, ld.Width, ld.Height);
return new ElementInfo
{
Id = 0,
Type = 3,
Width = ld.Width,
Height = ld.Height,
Children = tops,
};
} }
/// <summary> /// <summary>
@ -290,7 +300,11 @@ public static class LayoutImporter
// Resolve + attach children. Each child gets a FRESH base-chain set: // Resolve + attach children. Each child gets a FRESH base-chain set:
// the cycle guard is per-element, not shared across siblings. // the cycle guard is per-element, not shared across siblings.
foreach (var kv in d.Children) foreach (var kv in d.Children)
result.Children.Add(Resolve(dats, kv.Value, new HashSet<(uint, uint)>())); {
var child = Resolve(dats, kv.Value, new HashSet<(uint, uint)>());
SetOriginalParentSize(child, result.Width, result.Height);
result.Children.Add(child);
}
// Sub-window mount: a pure-container leaf (no own children, no own media) that inherits // Sub-window mount: a pure-container leaf (no own children, no own media) that inherits
// from a base WITH content attaches the base's subtree. Targets the gmInventoryUI panels // from a base WITH content attaches the base's subtree. Targets the gmInventoryUI panels
@ -313,42 +327,9 @@ public static class LayoutImporter
// FRONT of the backdrop. (B-Controller debug 2026-06-21; continuation of #145.) // FRONT of the backdrop. (B-Controller debug 2026-06-21; continuation of #145.)
result.ZLevel = self.ZLevel; result.ZLevel = self.ZLevel;
// Sub-layout slot sizing: when a sub-layout is mounted into a slot that is TALLER // Mounted descendants retain the base layout's design parent size. The runtime
// (or wider) than the sub-layout's own design height, the cascade of Bottom/Right // UiLayoutPolicy therefore performs the retail parent-size update from the correct
// anchored elements must be resized to fill the slot. Without this step, the // baseline when this slot is larger than the mounted layout.
// background element (0x10000226 for the Attributes tab, designed at 337px) captures
// _amB = slotH - designH = 238 and permanently stays at 337px, and every element
// within it with Bottom anchor stays at its design size too (list box at 160px
// instead of the correct 398px).
//
// Retail reference: UIElement::UpdateForParentSizeChange (C++ runtime) propagates a
// new parent height down the whole tree, updating each Bottom-anchored child's
// rect by maintaining its bottom margin. We replicate that cascade here at import
// time on the base-children subtree so the anchor-capture during the first render
// frame sees zero (or unchanged) margins — not the spurious "slot bigger than design"
// margin.
//
// Safe guard: only fire when the slot has explicit size AND the base children are
// smaller (i.e., this is the "slot bigger than design" case).
// Inventory/paperdoll slots are unaffected because their slot H already matches the
// sub-layout design H, so no cascade occurs.
if (result.Width > 0 && result.Height > 0)
{
foreach (var child in baseChildren)
{
var childAnchors = ElementReader.ToAnchors(child.Left, child.Top, child.Right, child.Bottom);
const AnchorEdges StretchAll = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom;
if (child.X == 0 && child.Y == 0
&& (childAnchors & StretchAll) == StretchAll // full-stretch background
&& child.Width <= result.Width
&& child.Height > 0 && child.Height < result.Height) // smaller than slot
{
// Cascade UpdateForParentSizeChange down the subtree: maintain each
// Bottom-anchored element's bottom margin while growing the parent height.
CascadeHeight(child, child.Height, result.Height);
}
}
}
} }
return result; return result;
@ -378,17 +359,19 @@ public static class LayoutImporter
Bottom = d.BottomEdge, Bottom = d.BottomEdge,
ReadOrder = d.ReadOrder, ReadOrder = d.ReadOrder,
ZLevel = d.ZLevel, ZLevel = d.ZLevel,
DefaultStateId = (uint)d.DefaultState,
DefaultStateName = (defState is "Undef" or "Undefined" or "0") ? "" : defState, DefaultStateName = (defState is "Undef" or "Undefined" or "0") ? "" : defState,
}; };
// DirectState (unnamed, key ""). // DirectState (unnamed, key "").
if (d.StateDesc is not null) if (d.StateDesc is not null)
ReadState(d.StateDesc, "", info); ReadState(d.StateDesc, UiStateInfo.DirectStateId, "", info);
// Named states (e.g. UIStateId.HideDetail → "HideDetail"). // Named states (e.g. UIStateId.HideDetail → "HideDetail").
foreach (var s in d.States) foreach (var s in d.States)
ReadState(s.Value, s.Key.ToString(), info); ReadState(s.Value, (uint)s.Key, s.Key.ToString(), info);
ElementReader.ApplyCanonicalLegacyProjection(info);
return info; return info;
} }
@ -398,14 +381,23 @@ public static class LayoutImporter
/// <c>info.StateCursors[name]</c>, and extract the font DID from property 0x1A /// <c>info.StateCursors[name]</c>, and extract the font DID from property 0x1A
/// (<c>ArrayBaseProperty → DataIdBaseProperty</c>) if not yet set. /// (<c>ArrayBaseProperty → DataIdBaseProperty</c>) if not yet set.
/// </summary> /// </summary>
private static void ReadState(StateDesc sd, string name, ElementInfo info) private static void ReadState(StateDesc sd, uint stateId, string name, ElementInfo info)
{ {
var state = new UiStateInfo
{
Id = stateId,
Name = name,
PassToChildren = sd.PassToChildren,
IncorporationFlags = (uint)sd.IncorporationFlags,
};
bool imageRead = false; bool imageRead = false;
foreach (var m in sd.Media) foreach (var m in sd.Media)
{ {
if (!imageRead && m is MediaDescImage img && img.File != 0) if (!imageRead && m is MediaDescImage img && img.File != 0)
{ {
info.StateMedia[name] = (img.File, (int)img.DrawMode); info.StateMedia[name] = (img.File, (int)img.DrawMode);
state.Image = new UiImageMedia(img.File, (int)img.DrawMode);
imageRead = true; imageRead = true;
} }
@ -415,9 +407,17 @@ public static class LayoutImporter
cursor.File, cursor.File,
checked((int)cursor.XHotspot), checked((int)cursor.XHotspot),
checked((int)cursor.YHotspot)); checked((int)cursor.YHotspot));
state.Cursor = info.StateCursors[name];
} }
} }
if (sd.Properties is not null)
{
foreach (var (propertyId, property) in sd.Properties)
state.Properties.Values[propertyId] = ConvertProperty(property);
}
info.States[stateId] = state;
// Font DID: Properties[0x1A] is ArrayBaseProperty{ DataIdBaseProperty }. // Font DID: Properties[0x1A] is ArrayBaseProperty{ DataIdBaseProperty }.
// Format doc §3: "ArrayBaseProperty containing ONE DataIdBaseProperty". // Format doc §3: "ArrayBaseProperty containing ONE DataIdBaseProperty".
if (info.FontDid == 0 && sd.Properties is not null if (info.FontDid == 0 && sd.Properties is not null
@ -476,6 +476,82 @@ public static class LayoutImporter
} }
} }
internal static UiPropertyValue ConvertProperty(BaseProperty property)
{
var value = new UiPropertyValue { MasterPropertyId = property.MasterPropertyId };
switch (property)
{
case EnumBaseProperty p:
value.Kind = UiPropertyKind.Enum;
value.UnsignedValue = p.Value;
break;
case BoolBaseProperty p:
value.Kind = UiPropertyKind.Bool;
value.BoolValue = p.Value;
break;
case DataIdBaseProperty p:
value.Kind = UiPropertyKind.DataId;
value.UnsignedValue = p.Value;
break;
case FloatBaseProperty p:
value.Kind = UiPropertyKind.Float;
value.FloatValue = p.Value;
break;
case IntegerBaseProperty p:
value.Kind = UiPropertyKind.Integer;
value.IntegerValue = p.Value;
break;
case StringInfoBaseProperty p:
value.Kind = UiPropertyKind.StringInfo;
value.StringInfoValue = new UiStringInfoValue(
p.Value.Token,
p.Value.StringId,
p.Value.TableId.DataId,
(byte)p.Value.Override,
p.Value.English,
p.Value.Comment);
break;
case ColorBaseProperty p:
value.Kind = UiPropertyKind.Color;
value.ColorValue = new UiColorValue(
p.Value.Blue,
p.Value.Green,
p.Value.Red,
p.Value.Alpha);
break;
case ArrayBaseProperty p:
value.Kind = UiPropertyKind.Array;
foreach (var item in p.Value)
value.ArrayValue.Add(ConvertProperty(item));
break;
case StructBaseProperty p:
value.Kind = UiPropertyKind.Struct;
foreach (var (key, item) in p.Value)
value.StructValue[key] = ConvertProperty(item);
break;
case VectorBaseProperty p:
value.Kind = UiPropertyKind.Vector;
value.VectorValue = p.Value;
break;
case Bitfield32BaseProperty p:
value.Kind = UiPropertyKind.Bitfield32;
value.UnsignedValue = p.Value;
break;
case Bitfield64BaseProperty p:
value.Kind = UiPropertyKind.Bitfield64;
value.UnsignedValue = p.Value;
break;
case InstanceIdBaseProperty p:
value.Kind = UiPropertyKind.InstanceId;
value.UnsignedValue = p.Value;
break;
default:
throw new NotSupportedException($"Unsupported UI base-property type {property.GetType().FullName}.");
}
return value;
}
// ── Prototype detection helpers ─────────────────────────────────────────── // ── Prototype detection helpers ───────────────────────────────────────────
/// <summary> /// <summary>
@ -534,59 +610,12 @@ public static class LayoutImporter
return null; return null;
} }
// ── Sub-layout slot sizing helpers ──────────────────────────────────────── // ── Raw-edge layout provenance ────────────────────────────────────────────
/// <summary> private static void SetOriginalParentSize(ElementInfo child, float width, float height)
/// Recursively propagates a parent-height change from <paramref name="oldParentH"/>
/// to <paramref name="newParentH"/> through <paramref name="el"/> and all its
/// descendants, replicating <c>UIElement::UpdateForParentSizeChange</c> from the
/// retail runtime.
///
/// <para>Three anchor cases for the vertical axis:
/// <list type="bullet">
/// <item>Top+Bottom (stretch): maintain top margin AND bottom margin; height grows.</item>
/// <item>Bottom only (pin-to-bottom, fixed height): maintain bottom margin; Y moves, H unchanged.</item>
/// <item>Top only or None: Y and H unchanged (pinned to top with fixed height).</item>
/// </list>
/// Recurse with the element's new height so grandchildren see the correct parent size.</para>
///
/// <para>This is called once at import time on base-children that are mounted into a
/// slot taller than the sub-layout's design height.</para>
/// </summary>
private static void CascadeHeight(ElementInfo el, float oldParentH, float newParentH)
{ {
float oldH = el.Height; child.OriginalParentWidth = width;
float newH = el.Height; child.OriginalParentHeight = height;
float oldY = el.Y; child.HasOriginalParentSize = true;
var anchors = ElementReader.ToAnchors(el.Left, el.Top, el.Right, el.Bottom);
bool hasTop = (anchors & AnchorEdges.Top) != 0;
bool hasBottom = (anchors & AnchorEdges.Bottom) != 0;
if (hasTop && hasBottom)
{
// Stretch: maintain both top and bottom margins.
// amT = el.Y (pinned to top, unchanged)
// amB = oldParentH - (el.Y + el.H)
float amB = oldParentH - (el.Y + el.Height);
newH = newParentH - el.Y - amB;
if (newH < 0f) newH = 0f;
el.Height = newH;
}
else if (hasBottom && !hasTop)
{
// Pin to bottom: maintain bottom margin, height fixed, Y moves.
// amB = oldParentH - (el.Y + el.H)
float amB = oldParentH - (el.Y + el.Height);
float newY = newParentH - amB - el.Height;
el.Y = newY;
// Height unchanged; newH = oldH for recursion.
}
// else: Top-only or None — element is top-pinned with fixed height; nothing moves.
// Recurse into children with the element's new height as their parent.
foreach (var child in el.Children)
CascadeHeight(child, oldH, newH);
} }
} }

View file

@ -157,7 +157,8 @@ public sealed class RadarController
if (_dragButton is not null) if (_dragButton is not null)
_dragButton.Visible = !snapshot.UiLocked; _dragButton.Visible = !snapshot.UiLocked;
if (_lockButton is not null) if (_lockButton is not null)
_lockButton.ActiveState = snapshot.UiLocked ? "LockedUI" : "UnlockedUI"; _lockButton.TrySetRetailState(
snapshot.UiLocked ? RetailUiStateIds.LockedUi : RetailUiStateIds.UnlockedUi);
} }
} }
@ -195,6 +196,7 @@ public sealed class RadarController
// behavioral widget so its 0x06004CC0 background stays the actual coordinate // behavioral widget so its 0x06004CC0 background stays the actual coordinate
// strip and no duplicate text node is needed. // strip and no duplicate text node is needed.
importedText.Centered = true; importedText.Centered = true;
importedText.OneLine = true;
importedText.Padding = 0f; importedText.Padding = 0f;
importedText.DatFont = datFont ?? importedText.DatFont; importedText.DatFont = datFont ?? importedText.DatFont;
importedText.ClickThrough = true; importedText.ClickThrough = true;
@ -214,6 +216,7 @@ public sealed class RadarController
Height = container.Height, Height = container.Height,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom, Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
Centered = true, Centered = true,
OneLine = true,
Padding = 0f, Padding = 0f,
DatFont = datFont, DatFont = datFont,
ClickThrough = true, ClickThrough = true,

View file

@ -146,6 +146,7 @@ public sealed class SelectedObjectController
Left = 0f, Top = 0f, Width = _name.Width, Height = NameBandHeight, Left = 0f, Top = 0f, Width = _name.Width, Height = NameBandHeight,
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
Centered = true, Centered = true,
OneLine = true,
DatFont = datFont, DatFont = datFont,
ClickThrough = true, ClickThrough = true,
AcceptsFocus = false, AcceptsFocus = false,
@ -215,7 +216,7 @@ public sealed class SelectedObjectController
if (guid is null) if (guid is null)
{ {
// Deselect: clear the overlay flash immediately too. // Deselect: clear the overlay flash immediately too.
SetOverlayState(""); SetOverlayState(UiStateInfo.DirectStateId);
_flashRemaining = 0; _flashRemaining = 0;
return; return;
} }
@ -227,7 +228,9 @@ public sealed class SelectedObjectController
// ── 3. Selection overlay: brief flash (retail container ObjectSelected // ── 3. Selection overlay: brief flash (retail container ObjectSelected
// = Pause(0.25s)→Normal). "StackedItemSelected" for stacks. ────────────── // = Pause(0.25s)→Normal). "StackedItemSelected" for stacks. ──────────────
SetOverlayState(_stackSize(g) > 1u ? "StackedItemSelected" : "ObjectSelected"); SetOverlayState(_stackSize(g) > 1u
? RetailUiStateIds.StackedItemSelected
: RetailUiStateIds.ObjectSelected);
_flashRemaining = FlashSeconds; _flashRemaining = FlashSeconds;
// ── 4. Health: query, and show the meter only if real health is already known. // ── 4. Health: query, and show the meter only if real health is already known.
@ -258,11 +261,11 @@ public sealed class SelectedObjectController
if (_flashRemaining <= 0) return; if (_flashRemaining <= 0) return;
_flashRemaining -= deltaSeconds; _flashRemaining -= deltaSeconds;
if (_flashRemaining <= 0) if (_flashRemaining <= 0)
SetOverlayState(""); // flash done → overlay back to blank SetOverlayState(UiStateInfo.DirectStateId); // flash done → overlay back to blank
} }
private void SetOverlayState(string state) private void SetOverlayState(uint state)
{ {
if (_overlay is not null) _overlay.ActiveState = state; _overlay?.TrySetRetailState(state);
} }
} }

View file

@ -56,8 +56,6 @@ public sealed class ToolbarController : IItemListDragHandler
private const uint CharacterButtonId = 0x10000199u; private const uint CharacterButtonId = 0x10000199u;
private const uint InventoryButtonId = 0x100001B1u; private const uint InventoryButtonId = 0x100001B1u;
private const string ButtonClosedState = "Normal";
private const string ButtonOpenState = "Highlight";
private readonly UiItemList?[] _slots = new UiItemList?[SlotIds.Length]; private readonly UiItemList?[] _slots = new UiItemList?[SlotIds.Length];
private readonly UiElement?[] _combatIndicators = new UiElement?[CombatIndicatorIds.Length]; private readonly UiElement?[] _combatIndicators = new UiElement?[CombatIndicatorIds.Length];
@ -247,7 +245,9 @@ public sealed class ToolbarController : IItemListDragHandler
private static void SetButtonOpen(UiButton? button, bool open) private static void SetButtonOpen(UiButton? button, bool open)
{ {
if (button is not null) if (button is not null)
button.ActiveState = open ? ButtonOpenState : ButtonClosedState; button.TrySetRetailState(open
? UiButtonStateMachine.Highlight
: UiButtonStateMachine.Normal);
} }
/// <summary> /// <summary>

View file

@ -28,7 +28,7 @@ namespace AcDream.App.UI.Layout;
/// <c>GL_REPEAT</c> on both S and T, so vertical tiling is always active. /// <c>GL_REPEAT</c> on both S and T, so vertical tiling is always active.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class UiDatElement : UiElement public sealed class UiDatElement : UiElement, IUiDatStateful
{ {
// DrawModeType enum values from DatReaderWriter.Enums. // DrawModeType enum values from DatReaderWriter.Enums.
// See docs/research/2026-06-15-layoutdesc-format.md §6. // See docs/research/2026-06-15-layoutdesc-format.md §6.
@ -51,8 +51,50 @@ public sealed class UiDatElement : UiElement
/// Falls back to DirectState if the named state is absent.</summary> /// Falls back to DirectState if the named state is absent.</summary>
public string ActiveState { get; set; } = ""; public string ActiveState { get; set; } = "";
public uint ActiveRetailStateId
{
get
{
if (string.IsNullOrEmpty(ActiveState))
return UiStateInfo.DirectStateId;
foreach (var (id, state) in _info.States)
if (string.Equals(state.Name, ActiveState, StringComparison.Ordinal))
return id;
return UiButtonStateMachine.TryStateId(ActiveState, out uint standard)
? standard
: RetailUiStateIds.TryStateId(ActiveState, out uint custom) ? custom : 0u;
}
}
public override string ActiveCursorStateName => ActiveState; public override string ActiveCursorStateName => ActiveState;
public bool TrySetRetailState(uint stateId)
{
if (stateId == UiStateInfo.DirectStateId)
{
if (!_info.States.ContainsKey(stateId) && !_info.StateMedia.ContainsKey(""))
return false;
ActiveState = "";
return true;
}
if (_info.States.TryGetValue(stateId, out var state))
{
ActiveState = state.Name;
return true;
}
string stateName = UiButtonStateMachine.StateName(stateId);
if (string.IsNullOrEmpty(stateName))
stateName = RetailUiStateIds.StateName(stateId);
if (!string.IsNullOrEmpty(stateName) && _info.StateMedia.ContainsKey(stateName))
{
ActiveState = stateName;
return true;
}
return false;
}
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param> /// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
/// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height). /// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height).
/// Returns (0,0,0) when the texture is not yet uploaded.</param> /// Returns (0,0,0) when the texture is not yet uploaded.</param>

View file

@ -0,0 +1,152 @@
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Dat property value kinds carried by a retail <c>StateDesc</c>. The enum mirrors
/// <c>BasePropertyType</c> without exposing DatReaderWriter types to the retained UI.
/// </summary>
public enum UiPropertyKind : byte
{
Enum,
Bool,
DataId,
Float,
Integer,
StringInfo,
Color,
Array,
Struct,
Vector,
Bitfield32,
Bitfield64,
InstanceId,
}
/// <summary>Exact byte representation of a retail ARGB property value.</summary>
public readonly record struct UiColorValue(byte Blue, byte Green, byte Red, byte Alpha);
/// <summary>Dat-independent snapshot of a retail <c>StringInfo</c>.</summary>
public readonly record struct UiStringInfoValue(
byte Token,
uint StringId,
uint TableId,
byte Override,
byte English,
byte Comment);
/// <summary>
/// Lossless, serializable representation of one supported retail base property.
/// Only the field selected by <see cref="Kind"/> is meaningful. Arrays and structs
/// recursively preserve their members and each member's master-property id.
/// </summary>
public sealed class UiPropertyValue
{
public UiPropertyKind Kind;
public uint MasterPropertyId;
public ulong UnsignedValue;
public int IntegerValue;
public float FloatValue;
public bool BoolValue;
public UiStringInfoValue StringInfoValue;
public UiColorValue ColorValue;
public Vector3 VectorValue;
public List<UiPropertyValue> ArrayValue = new();
public Dictionary<uint, UiPropertyValue> StructValue = new();
public UiPropertyValue Clone()
{
var clone = new UiPropertyValue
{
Kind = Kind,
MasterPropertyId = MasterPropertyId,
UnsignedValue = UnsignedValue,
IntegerValue = IntegerValue,
FloatValue = FloatValue,
BoolValue = BoolValue,
StringInfoValue = StringInfoValue,
ColorValue = ColorValue,
VectorValue = VectorValue,
};
foreach (var item in ArrayValue)
clone.ArrayValue.Add(item.Clone());
foreach (var (key, value) in StructValue)
clone.StructValue[key] = value.Clone();
return clone;
}
}
/// <summary>
/// State-scoped retail properties. Presence of a key is authoritative: an explicit
/// false, zero, centered justification, or empty collection still overrides a base
/// value during inheritance.
/// </summary>
public sealed class UiPropertyBag
{
public Dictionary<uint, UiPropertyValue> Values = new();
public bool TryGetValue(uint id, out UiPropertyValue value)
=> Values.TryGetValue(id, out value!);
public UiPropertyBag Clone()
{
var clone = new UiPropertyBag();
foreach (var (key, value) in Values)
clone.Values[key] = value.Clone();
return clone;
}
public static UiPropertyBag Merge(UiPropertyBag baseProperties, UiPropertyBag derivedProperties)
{
var merged = baseProperties.Clone();
foreach (var (key, value) in derivedProperties.Values)
merged.Values[key] = value.Clone();
return merged;
}
}
/// <summary>Primary render-surface media for a retail UI state.</summary>
public readonly record struct UiImageMedia(uint File, int DrawMode);
/// <summary>
/// Dat-independent state descriptor. DirectState uses
/// <see cref="DirectStateId"/> so it cannot collide with <c>UIStateId.Undef == 0</c>.
/// </summary>
public sealed class UiStateInfo
{
public const uint DirectStateId = uint.MaxValue;
public uint Id;
public string Name = "";
public bool PassToChildren;
public uint IncorporationFlags;
public UiImageMedia? Image;
public UiCursorMedia? Cursor;
public UiPropertyBag Properties = new();
public UiStateInfo Clone()
=> new()
{
Id = Id,
Name = Name,
PassToChildren = PassToChildren,
IncorporationFlags = IncorporationFlags,
Image = Image,
Cursor = Cursor,
Properties = Properties.Clone(),
};
public static UiStateInfo Merge(UiStateInfo baseState, UiStateInfo derivedState)
=> new()
{
Id = derivedState.Id,
Name = derivedState.Name,
PassToChildren = derivedState.PassToChildren,
IncorporationFlags = derivedState.IncorporationFlags,
Image = derivedState.Image ?? baseState.Image,
Cursor = derivedState.Cursor ?? baseState.Cursor,
Properties = UiPropertyBag.Merge(baseState.Properties, derivedState.Properties),
};
}

View file

@ -27,6 +27,9 @@ public static class VitalsController
public const uint Stamina = 0x100000EC; public const uint Stamina = 0x100000EC;
/// <summary>Dat element id for the Mana meter (0x100000EE).</summary> /// <summary>Dat element id for the Mana meter (0x100000EE).</summary>
public const uint Mana = 0x100000EE; public const uint Mana = 0x100000EE;
public const uint HealthText = 0x100000EB;
public const uint StaminaText = 0x100000ED;
public const uint ManaText = 0x100000EF;
/// <summary> /// <summary>
/// Bind live vitals data providers to the Health, Stamina, and Mana meter /// Bind live vitals data providers to the Health, Stamina, and Mana meter
@ -50,16 +53,16 @@ public static class VitalsController
Func<string> staminaText, Func<string> staminaText,
Func<string> manaText) Func<string> manaText)
{ {
BindMeter(layout, Health, healthPct, healthText); BindMeter(layout, Health, HealthText, healthPct, healthText);
BindMeter(layout, Stamina, staminaPct, staminaText); BindMeter(layout, Stamina, StaminaText, staminaPct, staminaText);
BindMeter(layout, Mana, manaPct, manaText); BindMeter(layout, Mana, ManaText, manaPct, manaText);
} }
/// <summary>White cur/max numbers — matches the former <c>UiMeter.LabelColor</c> default.</summary> /// <summary>White cur/max numbers — matches the former <c>UiMeter.LabelColor</c> default.</summary>
private static readonly Vector4 NumberColor = new(1f, 1f, 1f, 1f); private static readonly Vector4 NumberColor = new(1f, 1f, 1f, 1f);
private static void BindMeter( private static void BindMeter(
ImportedLayout layout, uint id, ImportedLayout layout, uint id, uint textId,
Func<float> pct, Func<float> pct,
Func<string> text) Func<string> text)
{ {
@ -68,31 +71,22 @@ public static class VitalsController
m.Fill = () => pct(); m.Fill = () => pct();
// Retail gmVitalsUI renders the cur/max as a real UIElement_Text centered over the // Retail gmVitalsUI binds the authored UIElement_Text over each meter. The
// bar — NOT a meter-internal label. Attach a centered UiText (non-interactive // importer already built and registered that node; do not synthesize a duplicate.
// decoration) that fills + stretches with the meter, and stop the meter drawing its
// own label. UiText.Centered uses the SAME centering formula the meter's overlay did,
// so the numbers stay pixel-identical (locked by the visual gate).
m.Label = () => null; m.Label = () => null;
if (layout.FindElement(textId) is not UiText number) return;
var number = new UiText number.Centered = true;
{ number.RightAligned = false;
Left = 0f, Top = 0f, Width = m.Width, Height = m.Height, number.OneLine = true;
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom, number.Selectable = false;
Centered = true, number.DatFont ??= m.DatFont;
DatFont = m.DatFont, // the same dat font the meter used for its label number.LinesProvider = () =>
ClickThrough = true, // decoration: no focus / selection / drag
AcceptsFocus = false,
IsEditControl = false,
CapturesPointerDrag = false,
LinesProvider = () =>
{ {
var s = text(); var s = text();
return string.IsNullOrEmpty(s) return string.IsNullOrEmpty(s)
? Array.Empty<UiText.Line>() ? Array.Empty<UiText.Line>()
: new[] { new UiText.Line(s, NumberColor) }; : new[] { new UiText.Line(s, NumberColor) };
},
}; };
m.AddChild(number);
} }
} }

View file

@ -30,10 +30,17 @@ namespace AcDream.App.UI;
/// earlier dev-scaffold widget with no dat sprites. /// earlier dev-scaffold widget with no dat sprites.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class UiButton : UiElement public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
{ {
private readonly ElementInfo _info; private readonly ElementInfo _info;
private readonly Func<uint, (uint tex, int w, int h)> _resolve; private readonly Func<uint, (uint tex, int w, int h)> _resolve;
private readonly HashSet<uint> _availableStates = new();
private bool _pressed;
private bool _pointerOver;
private bool _selected;
private bool _hotClicking;
private bool _suppressNextClick;
private double _nextHotClickTime = double.NaN;
/// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary> /// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary>
public Action? OnClick { get; set; } public Action? OnClick { get; set; }
@ -57,14 +64,84 @@ public sealed class UiButton : UiElement
/// <summary>Label horizontal alignment options.</summary> /// <summary>Label horizontal alignment options.</summary>
public enum LabelAlignment { Center, Left } public enum LabelAlignment { Center, Left }
public bool ToggleBehavior { get; }
public bool RolloverEnabled { get; }
public bool HotClickEnabled { get; }
public float HotClickInitialDelay { get; }
public float HotClickRepeatInterval { get; }
public bool Selected
{
get => _selected;
set
{
if (_selected == value) return;
_selected = value;
UpdateVisualState();
}
}
/// <summary> /// <summary>
/// Active state name, runtime-settable (e.g. Max/Min toggling Normal ↔ Minimized). /// Active state name, runtime-settable (e.g. Max/Min toggling Normal ↔ Minimized).
/// Matches <see cref="UiDatElement.ActiveState"/>. /// Matches <see cref="UiDatElement.ActiveState"/>.
/// </summary> /// </summary>
public string ActiveState { get; set; } = ""; public string ActiveState { get; set; } = "";
public uint ActiveRetailStateId
{
get
{
if (string.IsNullOrEmpty(ActiveState))
return UiStateInfo.DirectStateId;
foreach (var (id, state) in _info.States)
if (string.Equals(state.Name, ActiveState, StringComparison.Ordinal))
return id;
return UiButtonStateMachine.TryStateId(ActiveState, out uint standard)
? standard
: RetailUiStateIds.TryStateId(ActiveState, out uint custom) ? custom : 0u;
}
}
public override string ActiveCursorStateName => ActiveState; public override string ActiveCursorStateName => ActiveState;
public bool TrySetRetailState(uint stateId)
{
if (ToggleBehavior && stateId is UiButtonStateMachine.Normal or UiButtonStateMachine.Highlight)
{
Selected = stateId == UiButtonStateMachine.Highlight;
return true;
}
if (stateId == UiButtonStateMachine.Ghosted)
{
Enabled = false;
return true;
}
if (!Enabled && stateId != UiButtonStateMachine.Ghosted)
Enabled = true;
if (stateId == UiStateInfo.DirectStateId)
{
if (!_info.States.ContainsKey(stateId) && !_info.StateMedia.ContainsKey(""))
return false;
ActiveState = "";
return true;
}
if (_info.States.TryGetValue(stateId, out var state))
{
ActiveState = state.Name;
return true;
}
string stateName = UiButtonStateMachine.StateName(stateId);
if (string.IsNullOrEmpty(stateName))
stateName = RetailUiStateIds.StateName(stateId);
if (!string.IsNullOrEmpty(stateName) && _info.StateMedia.ContainsKey(stateName))
{
ActiveState = stateName;
return true;
}
return false;
}
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param> /// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
/// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height). /// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height).
/// Returns (0,0,0) when the texture is not yet uploaded.</param> /// Returns (0,0,0) when the texture is not yet uploaded.</param>
@ -74,6 +151,25 @@ public sealed class UiButton : UiElement
_resolve = resolve; _resolve = resolve;
ClickThrough = false; // buttons are interactive — opt OUT of click-through ClickThrough = false; // buttons are interactive — opt OUT of click-through
foreach (uint stateId in info.States.Keys)
if (stateId != UiStateInfo.DirectStateId)
_availableStates.Add(stateId);
foreach (string stateName in info.StateMedia.Keys)
if (UiButtonStateMachine.TryStateId(stateName, out uint stateId))
_availableStates.Add(stateId);
ToggleBehavior = info.TryGetEffectiveBool(0x0Bu, out bool toggle) && toggle;
RolloverEnabled = info.TryGetEffectiveBool(0x13u, out bool rollover) && rollover;
HotClickEnabled = info.TryGetEffectiveBool(0x0Fu, out bool hotClick) && hotClick;
HotClickInitialDelay = info.TryGetEffectiveFloat(0x10u, out float initialDelay)
? initialDelay
: 0f;
HotClickRepeatInterval = info.TryGetEffectiveFloat(0x11u, out float repeatInterval)
? repeatInterval
: 0f;
_selected = info.TryGetEffectiveBool(0x0Eu, out bool selected) && selected;
bool disabled = info.TryGetEffectiveBool(0x0Du, out bool ghosted) && ghosted;
// State defaulting matches UiDatElement exactly: // State defaulting matches UiDatElement exactly:
// DefaultStateName wins; else "Normal" if that state has a sprite; else DirectState (""). // DefaultStateName wins; else "Normal" if that state has a sprite; else DirectState ("").
if (!string.IsNullOrEmpty(info.DefaultStateName)) if (!string.IsNullOrEmpty(info.DefaultStateName))
@ -81,6 +177,9 @@ public sealed class UiButton : UiElement
else if (info.StateMedia.ContainsKey("Normal")) else if (info.StateMedia.ContainsKey("Normal"))
ActiveState = "Normal"; ActiveState = "Normal";
// else ActiveState stays "" (DirectState) // else ActiveState stays "" (DirectState)
Enabled = !disabled;
UpdateVisualState();
} }
/// <summary>The button draws its own face + label; any dat label child is reproduced /// <summary>The button draws its own face + label; any dat label child is reproduced
@ -128,7 +227,103 @@ public sealed class UiButton : UiElement
public override bool OnEvent(in UiEvent e) public override bool OnEvent(in UiEvent e)
{ {
if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; } switch (e.Type)
{
case UiEventType.HoverEnter:
_pointerOver = true;
UpdateVisualState();
return true;
case UiEventType.HoverLeave:
_pointerOver = false;
UpdateVisualState();
return true;
case UiEventType.MouseDown:
_pointerOver = ContainsLocal(e.Data1, e.Data2);
_pressed = true;
UpdateVisualState();
if (HotClickEnabled && Enabled)
{
OnClick?.Invoke();
_hotClicking = true;
_nextHotClickTime = double.NaN;
}
return true;
case UiEventType.MouseMove:
if (_pressed)
{
_pointerOver = ContainsLocal(e.Data1, e.Data2);
UpdateVisualState();
return true;
}
return false;
case UiEventType.MouseUp:
_pointerOver = ContainsLocal(e.Data1, e.Data2);
_suppressNextClick = _hotClicking && _pointerOver;
_hotClicking = false;
_nextHotClickTime = double.NaN;
if (_pressed && _pointerOver && Enabled && ToggleBehavior)
_selected = !_selected;
_pressed = false;
UpdateVisualState();
return true;
case UiEventType.Click:
if (!Enabled) return true;
if (_suppressNextClick)
{
_suppressNextClick = false;
return true;
}
OnClick?.Invoke();
return OnClick is not null;
default:
return false; return false;
} }
} }
protected override void OnEnabledChanged()
{
if (!Enabled)
{
_pressed = false;
_hotClicking = false;
_nextHotClickTime = double.NaN;
}
UpdateVisualState();
}
public void OnGlobalUiTime(double nowSeconds)
{
if (!_hotClicking || !HotClickEnabled)
return;
if (double.IsNaN(_nextHotClickTime))
_nextHotClickTime = nowSeconds + HotClickInitialDelay;
if (!_pointerOver && nowSeconds >= _nextHotClickTime)
{
_nextHotClickTime = nowSeconds;
return;
}
if (_pointerOver && nowSeconds >= _nextHotClickTime)
{
OnClick?.Invoke();
_nextHotClickTime += HotClickRepeatInterval;
}
}
private bool ContainsLocal(int x, int y)
=> x >= 0 && y >= 0 && x < Width && y < Height;
private void UpdateVisualState()
{
uint requested = UiButtonStateMachine.RequestedState(new UiButtonVisualInput(
Disabled: !Enabled,
Selected: _selected,
RolloverEnabled: RolloverEnabled,
Pressed: _pressed,
PointerOver: _pointerOver));
if (_availableStates.Contains(requested))
ActiveState = UiButtonStateMachine.StateName(requested);
}
}

View file

@ -0,0 +1,79 @@
namespace AcDream.App.UI;
/// <summary>Inputs to retail <c>UIElement_Button::UpdateState_</c>.</summary>
public readonly record struct UiButtonVisualInput(
bool Disabled,
bool Selected,
bool RolloverEnabled,
bool Pressed,
bool PointerOver);
/// <summary>
/// GL-free retail button visual-state policy. State ids are the numeric
/// <c>UIStateId</c> values from the DAT.
/// </summary>
public static class UiButtonStateMachine
{
public const uint Normal = 1u;
public const uint NormalRollover = 2u;
public const uint NormalPressed = 3u;
public const uint Highlight = 6u;
public const uint HighlightRollover = 7u;
public const uint HighlightPressed = 8u;
public const uint Ghosted = 13u;
public static uint RequestedState(UiButtonVisualInput input)
{
if (input.Disabled)
return Ghosted;
uint requested = input.Selected ? Highlight : Normal;
if (input.Pressed || (input.RolloverEnabled && input.PointerOver))
requested = input.Selected ? HighlightRollover : NormalRollover;
if (input.Pressed && input.PointerOver)
requested = input.Selected ? HighlightPressed : NormalPressed;
return requested;
}
/// <summary>
/// Returns the requested state when it exists; otherwise returns the current
/// state unchanged. Retail does not search for a nearest fallback.
/// </summary>
public static uint ResolveState(
uint currentState,
UiButtonVisualInput input,
IReadOnlySet<uint> availableStates)
{
uint requested = RequestedState(input);
return availableStates.Contains(requested) ? requested : currentState;
}
public static string StateName(uint stateId)
=> stateId switch
{
Normal => "Normal",
NormalRollover => "Normal_rollover",
NormalPressed => "Normal_pressed",
Highlight => "Highlight",
HighlightRollover => "Highlight_rollover",
HighlightPressed => "Highlight_pressed",
Ghosted => "Ghosted",
_ => "",
};
public static bool TryStateId(string stateName, out uint stateId)
{
stateId = stateName switch
{
"Normal" => Normal,
"Normal_rollover" => NormalRollover,
"Normal_pressed" => NormalPressed,
"Highlight" => Highlight,
"Highlight_rollover" => HighlightRollover,
"Highlight_pressed" => HighlightPressed,
"Ghosted" => Ghosted,
_ => 0u,
};
return stateId != 0;
}
}

View file

@ -121,7 +121,17 @@ public abstract class UiElement
// ── State flags ───────────────────────────────────────────────────── // ── State flags ─────────────────────────────────────────────────────
public bool Visible { get; set; } = true; public bool Visible { get; set; } = true;
public bool Enabled { get; set; } = true; private bool _enabled = true;
public bool Enabled
{
get => _enabled;
set
{
if (_enabled == value) return;
_enabled = value;
OnEnabledChanged();
}
}
/// <summary> /// <summary>
/// If true, <see cref="HitTest"/> skips this element — the event /// If true, <see cref="HitTest"/> skips this element — the event
@ -206,7 +216,28 @@ public abstract class UiElement
/// <summary>Edges this element anchors to in its parent. Default Left|Top /// <summary>Edges this element anchors to in its parent. Default Left|Top
/// (pinned top-left, fixed size — no reflow). Left|Right stretches width.</summary> /// (pinned top-left, fixed size — no reflow). Left|Right stretches width.</summary>
public AnchorEdges Anchors { get; set; } = AnchorEdges.Left | AnchorEdges.Top; private AnchorEdges _anchors = AnchorEdges.Left | AnchorEdges.Top;
/// <summary>Edges this programmatic element anchors to in its parent. Assigning
/// this compatibility policy explicitly opts out of an imported DAT
/// <see cref="LayoutPolicy"/>.</summary>
public AnchorEdges Anchors
{
get => _anchors;
set
{
_anchors = value;
LayoutPolicy = null;
_anchorCaptured = false;
}
}
/// <summary>
/// Exact raw-edge policy for imported DAT elements. Null for roots and
/// programmatic widgets. Controllers may set null to opt out or call
/// <see cref="UiLayoutPolicy.Rebase"/> after intentional geometry changes.
/// </summary>
public UiLayoutPolicy? LayoutPolicy { get; set; }
// ── Tree structure ────────────────────────────────────────────────── // ── Tree structure ──────────────────────────────────────────────────
public UiElement? Parent { get; private set; } public UiElement? Parent { get; private set; }
@ -223,7 +254,9 @@ public abstract class UiElement
public virtual bool RemoveChild(UiElement child) public virtual bool RemoveChild(UiElement child)
{ {
if (!_children.Remove(child)) return false; if (!_children.Contains(child)) return false;
FindRoot()?.OnSubtreeRemoving(child);
_children.Remove(child);
child.Parent = null; child.Parent = null;
return true; return true;
} }
@ -271,6 +304,9 @@ public abstract class UiElement
/// <summary>Per-frame tick (animations, timers, caret blink).</summary> /// <summary>Per-frame tick (animations, timers, caret blink).</summary>
protected virtual void OnTick(double deltaSeconds) { } protected virtual void OnTick(double deltaSeconds) { }
/// <summary>Called synchronously after <see cref="Enabled"/> changes.</summary>
protected virtual void OnEnabledChanged() { }
/// <summary> /// <summary>
/// Custom hit-test override. Default is a rectangle containment /// Custom hit-test override. Default is a rectangle containment
/// check on (<see cref="Width"/>, <see cref="Height"/>). /// check on (<see cref="Width"/>, <see cref="Height"/>).
@ -296,8 +332,8 @@ public abstract class UiElement
public virtual (uint tex, int w, int h)? GetDragGhost() => null; public virtual (uint tex, int w, int h)? GetDragGhost() => null;
/// <summary> /// <summary>
/// Tooltip text for this widget. Retail fires event 0x07 after /// Tooltip text for this widget. Retail fires event 0x07 after the configured
/// ~1000ms hover, then queries the widget's virtual "GetString" /// hover delay (0.25 seconds by default), then queries the widget's virtual "GetString"
/// (vtable +0x88) to render the tooltip body. /// (vtable +0x88) to render the tooltip body.
/// </summary> /// </summary>
public virtual string? GetTooltipText() => null; public virtual string? GetTooltipText() => null;
@ -418,6 +454,22 @@ public abstract class UiElement
/// Called by the parent each frame before drawing children.</summary> /// Called by the parent each frame before drawing children.</summary>
internal void ApplyAnchor(float parentW, float parentH) internal void ApplyAnchor(float parentW, float parentH)
{ {
if (LayoutPolicy is not null)
{
var current = UiPixelRect.FromPositionAndSize(
(int)Left,
(int)Top,
(int)Width,
(int)Height);
var parent = UiPixelRect.FromPositionAndSize(0, 0, (int)parentW, (int)parentH);
var next = LayoutPolicy.Apply(current, parent);
Left = next.X0;
Top = next.Y0;
Width = next.Width;
Height = next.Height;
return;
}
if (Anchors == AnchorEdges.None) return; if (Anchors == AnchorEdges.None) return;
if (!_anchorCaptured) if (!_anchorCaptured)
{ {

View file

@ -39,8 +39,8 @@ public readonly record struct UiEvent(
/// Evidence from decompile: /// Evidence from decompile:
/// - 0x01 click — chunk_00470000.c ~11140, chunk_004C0000.c ~9270 /// - 0x01 click — chunk_00470000.c ~11140, chunk_004C0000.c ~9270
/// - 0x05/0x06 hover — chunk_00460000.c ~6253 /// - 0x05/0x06 hover — chunk_00460000.c ~6253
/// - 0x07 tooltip — chunk_00460000.c ~6253 (delayed via /// - 0x07 tooltip — chunk_00460000.c ~6253 (the UI manager polls the
/// Device::RegisterTimerEvent(7, widget, delayMs)) /// hovered element's tooltip deadline before global time)
/// - 0x0A scroll — chunk_00470000.c ~11210 /// - 0x0A scroll — chunk_00470000.c ~11210
/// - 0x0E right-click— chunk_004A0000.c ~2674 /// - 0x0E right-click— chunk_004A0000.c ~2674
/// - 0x15 drag begin — chunk_004A0000.c ~2707 /// - 0x15 drag begin — chunk_004A0000.c ~2707

View file

@ -22,6 +22,7 @@ namespace AcDream.App.UI;
/// </summary> /// </summary>
public sealed class UiField : UiElement public sealed class UiField : UiElement
{ {
public uint ElementId { get; set; }
public UiDatFont? DatFont { get; set; } public UiDatFont? DatFont { get; set; }
public AcDream.App.Rendering.BitmapFont? Font { get; set; } public AcDream.App.Rendering.BitmapFont? Font { get; set; }
public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f); public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f);
@ -30,6 +31,8 @@ public sealed class UiField : UiElement
public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f); public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f);
public float Padding { get; set; } = 4f; public float Padding { get; set; } = 4f;
public int MaxCharacters { get; set; } = 0xFFFF; public int MaxCharacters { get; set; } = 0xFFFF;
public bool OneLine { get; set; } = true;
public bool Selectable { get; set; }
/// <summary>Keyboard device for clipboard (Ctrl+C/X/V) + modifier state (Ctrl/Shift). /// <summary>Keyboard device for clipboard (Ctrl+C/X/V) + modifier state (Ctrl/Shift).
/// Wired by the host from <see cref="UiHost.Keyboard"/>.</summary> /// Wired by the host from <see cref="UiHost.Keyboard"/>.</summary>
@ -38,6 +41,8 @@ public sealed class UiField : UiElement
/// <summary>Dat sprite resolver (id → GL texture + size) for the focused-field /// <summary>Dat sprite resolver (id → GL texture + size) for the focused-field
/// background. Null = fall back to the flat <see cref="BackgroundColor"/> rect.</summary> /// background. Null = fall back to the flat <see cref="BackgroundColor"/> rect.</summary>
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; } public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
/// <summary>Unfocused/default state sprite imported from the DAT.</summary>
public uint BackgroundSprite { get; set; }
/// <summary>Gold "lit" field background drawn when focused (retail Normal_focussed /// <summary>Gold "lit" field background drawn when focused (retail Normal_focussed
/// state, RenderSurface 0x060011AB). 0 = no focus sprite.</summary> /// state, RenderSurface 0x060011AB). 0 = no focus sprite.</summary>
public uint FocusFieldSprite { get; set; } public uint FocusFieldSprite { get; set; }
@ -262,7 +267,8 @@ public sealed class UiField : UiElement
protected override void OnDraw(UiRenderContext ctx) protected override void OnDraw(UiRenderContext ctx)
{ {
// Focused = "write mode": draw the gold lit field sprite (retail Normal_focussed). // Focused = "write mode": draw the gold lit field sprite (retail Normal_focussed).
// Unfocused: the flat translucent rect. Both go through the sprite bucket // Unfocused: draw the imported default state, then the flat translucent fallback.
// Both go through the sprite bucket
// (DrawFill / DrawSprite) so the text — also sprite-bucket — draws on top. // (DrawFill / DrawSprite) so the text — also sprite-bucket — draws on top.
bool lit = _focused && SpriteResolve is not null && FocusFieldSprite != 0; bool lit = _focused && SpriteResolve is not null && FocusFieldSprite != 0;
if (lit) if (lit)
@ -271,6 +277,16 @@ public sealed class UiField : UiElement
if (tex != 0 && tw > 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); if (tex != 0 && tw > 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
else lit = false; else lit = false;
} }
if (!lit && SpriteResolve is not null && BackgroundSprite != 0)
{
var (tex, tw, th) = SpriteResolve(BackgroundSprite);
if (tex != 0 && tw > 0 && th > 0)
{
ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f,
Width / tw, Height / th, Vector4.One);
lit = true;
}
}
if (!lit) ctx.DrawFill(0, 0, Width, Height, BackgroundColor); if (!lit) ctx.DrawFill(0, 0, Width, Height, BackgroundColor);
float lh = DatFont?.LineHeight ?? Font?.LineHeight ?? 14f; float lh = DatFont?.LineHeight ?? Font?.LineHeight ?? 14f;
@ -366,11 +382,11 @@ public sealed class UiField : UiElement
case UiEventType.MouseDown: case UiEventType.MouseDown:
_caret = HitCharX(e.Data1); _caret = HitCharX(e.Data1);
_selAnchor = _caret; // anchor; a drag will extend, a plain click won't _selAnchor = Selectable ? _caret : null;
_selecting = true; _selecting = Selectable;
return true; return true;
case UiEventType.MouseMove: case UiEventType.MouseMove:
if (_selecting) _caret = HitCharX(e.Data1); if (Selectable && _selecting) _caret = HitCharX(e.Data1);
return true; return true;
case UiEventType.MouseUp: case UiEventType.MouseUp:
_selecting = false; _selecting = false;
@ -387,15 +403,15 @@ public sealed class UiField : UiElement
{ {
switch (key) switch (key)
{ {
case Silk.NET.Input.Key.A: SelectAll(); return true; case Silk.NET.Input.Key.A when Selectable: SelectAll(); return true;
case Silk.NET.Input.Key.C: CopySelection(); return true; case Silk.NET.Input.Key.C when Selectable: CopySelection(); return true;
case Silk.NET.Input.Key.X: CutSelection(); return true; case Silk.NET.Input.Key.X when Selectable: CutSelection(); return true;
case Silk.NET.Input.Key.V: Paste(); return true; case Silk.NET.Input.Key.V: Paste(); return true;
} }
return true; // swallow other Ctrl combos while typing return true; // swallow other Ctrl combos while typing
} }
bool shift = ShiftHeld(); bool shift = Selectable && ShiftHeld();
switch (key) switch (key)
{ {
case Silk.NET.Input.Key.Enter: case Silk.NET.Input.Key.Enter:

View file

@ -0,0 +1,151 @@
namespace AcDream.App.UI;
/// <summary>An inclusive integer pixel box, matching retail <c>Box2D</c>.</summary>
public readonly record struct UiPixelRect(int X0, int Y0, int X1, int Y1)
{
public int Width => X1 - X0 + 1;
public int Height => Y1 - Y0 + 1;
public static UiPixelRect FromPositionAndSize(int x, int y, int width, int height)
=> new(x, y, x + width - 1, y + height - 1);
}
/// <summary>
/// Exact retail raw-edge layout policy from
/// <c>UIElement::UpdateForParentSizeChange @ 0x00462640</c>. Imported DAT elements
/// retain all four independent modes; programmatic widgets continue to use
/// <see cref="AnchorEdges"/>.
/// </summary>
public sealed class UiLayoutPolicy
{
public uint LeftMode { get; }
public uint TopMode { get; }
public uint RightMode { get; }
public uint BottomMode { get; }
public UiPixelRect OriginalChild { get; private set; }
public UiPixelRect OriginalParent { get; private set; }
public bool PreserveCurrentWhenEmpty { get; set; }
public UiLayoutPolicy(
uint leftMode,
uint topMode,
uint rightMode,
uint bottomMode,
UiPixelRect originalChild,
UiPixelRect originalParent)
{
ValidateMode(leftMode, nameof(leftMode));
ValidateMode(topMode, nameof(topMode));
ValidateMode(rightMode, nameof(rightMode));
ValidateMode(bottomMode, nameof(bottomMode));
LeftMode = leftMode;
TopMode = topMode;
RightMode = rightMode;
BottomMode = bottomMode;
OriginalChild = originalChild;
OriginalParent = originalParent;
}
/// <summary>
/// Makes a controller-supplied geometry change the new design baseline while
/// retaining the raw retail edge modes.
/// </summary>
public void Rebase(UiPixelRect child, UiPixelRect parent)
{
OriginalChild = child;
OriginalParent = parent;
}
public UiPixelRect Apply(UiPixelRect currentChild, UiPixelRect currentParent)
=> Apply(
LeftMode,
TopMode,
RightMode,
BottomMode,
OriginalChild,
OriginalParent,
currentChild,
currentParent,
PreserveCurrentWhenEmpty);
public static UiPixelRect Apply(
uint leftMode,
uint topMode,
uint rightMode,
uint bottomMode,
UiPixelRect originalChild,
UiPixelRect originalParent,
UiPixelRect currentChild,
UiPixelRect currentParent,
bool preserveCurrentWhenEmpty = false)
{
ValidateMode(leftMode, nameof(leftMode));
ValidateMode(topMode, nameof(topMode));
ValidateMode(rightMode, nameof(rightMode));
ValidateMode(bottomMode, nameof(bottomMode));
int deltaX = currentParent.Width - originalParent.Width;
int deltaY = currentParent.Height - originalParent.Height;
double scaleX = originalParent.Width != 0
? (double)currentParent.Width / originalParent.Width
: 0d;
double scaleY = originalParent.Height != 0
? (double)currentParent.Height / originalParent.Height
: 0d;
int x0 = ApplyNear(leftMode, originalChild.X0, originalChild.Width,
currentParent.Width, deltaX, scaleX);
int y0 = ApplyNear(topMode, originalChild.Y0, originalChild.Height,
currentParent.Height, deltaY, scaleY);
int x1 = ApplyFar(rightMode, originalChild.X1, originalChild.Width,
currentParent.Width, deltaX, scaleX);
int y1 = ApplyFar(bottomMode, originalChild.Y1, originalChild.Height,
currentParent.Height, deltaY, scaleY);
if (currentChild.Width != 0 || currentChild.Height != 0 || preserveCurrentWhenEmpty)
{
if (leftMode == 0) x0 = currentChild.X0;
if (topMode == 0) y0 = currentChild.Y0;
if (rightMode == 0) x1 = currentChild.X1;
if (bottomMode == 0) y1 = currentChild.Y1;
}
return new UiPixelRect(x0, y0, x1, y1);
}
private static int ApplyNear(
uint mode,
int originalEdge,
int originalSize,
int currentParentSize,
int parentDelta,
double scale)
=> mode switch
{
2 => originalEdge + parentDelta,
3 => currentParentSize / 2 - originalSize / 2,
4 => (int)(originalEdge * scale),
_ => originalEdge,
};
private static int ApplyFar(
uint mode,
int originalEdge,
int originalSize,
int currentParentSize,
int parentDelta,
double scale)
=> mode switch
{
1 => originalEdge + parentDelta,
3 => currentParentSize / 2 + originalSize / 2 - 1,
4 => (int)(originalEdge * scale),
_ => originalEdge,
};
private static void ValidateMode(uint mode, string parameterName)
{
if (mode > 4)
throw new ArgumentOutOfRangeException(parameterName, mode, "Retail edge mode must be in 0..4.");
}
}

View file

@ -9,24 +9,15 @@ namespace AcDream.App.UI;
public enum ResizeEdges { None = 0, Left = 1, Right = 2, Top = 4, Bottom = 8 } public enum ResizeEdges { None = 0, Left = 1, Right = 2, Top = 4, Bottom = 8 }
/// <summary> /// <summary>
/// Top-level UI container. Implements the retail "Device" responsibilities /// Top-level UI container. Implements the retail <c>UIElementManager</c> responsibilities
/// (mouse cursor tracking, keyboard focus, modal overlay, mouse capture, /// (mouse cursor tracking, keyboard focus, modal overlay, mouse capture,
/// drag-drop state machine, tooltip timer). Routes Silk.NET input events /// drag-drop state machine, tooltip timer). Routes Silk.NET input events
/// into the widget tree with retail-faithful <see cref="UiEvent"/> /// into the widget tree with retail-faithful <see cref="UiEvent"/>
/// semantics. /// semantics.
/// ///
/// Retail analog: the <c>DAT_00837ff4</c> Device object (see /// Retail analog: <c>UIElementManager::UseTime @ 0x0045CFD0</c>. Tooltip
/// <c>docs/research/retail-ui/04-input-events.md §2</c>). That object has /// deadlines are polled before global time message 3; there is no generic Device
/// a ~20-slot vtable; the methods we emulate here are: /// timer queue in the named client.
///
/// <list type="bullet">
/// <item>+0x18 / +0x1C : <see cref="MouseX"/> / <see cref="MouseY"/></item>
/// <item>+0x34 : <see cref="RegisterTimerEvent"/> (tooltip delay)</item>
/// <item>+0x38 : <see cref="FireEvent"/></item>
/// <item>+0x44 : <see cref="KeyboardFocus"/></item>
/// <item>+0x48 / +0x4C : <see cref="SetCapture"/> / <see cref="ReleaseCapture"/></item>
/// <item>+0x74 / +0x78 : drag cursor set / reset</item>
/// </list>
/// ///
/// When no widget consumes an event, the <see cref="WorldMouseFallThrough"/> /// When no widget consumes an event, the <see cref="WorldMouseFallThrough"/>
/// or <see cref="WorldKeyFallThrough"/> event fires so the game world /// or <see cref="WorldKeyFallThrough"/> event fires so the game world
@ -125,8 +116,10 @@ public sealed class UiRoot : UiElement
// Hover / tooltip tracking. // Hover / tooltip tracking.
private UiElement? _hoverWidget; private UiElement? _hoverWidget;
private long _hoverStartedMs; private long _hoverStartedMs;
private const int TooltipDelayMs = 1000; // retail typical public int TooltipDelayMs { get; set; } = 250;
public int TooltipDurationMs { get; set; } = 10_000;
private bool _tooltipFired; private bool _tooltipFired;
private long _tooltipShownMs;
private long _nowMs; private long _nowMs;
@ -152,10 +145,92 @@ public sealed class UiRoot : UiElement
public override void AddChild(UiElement child) public override void AddChild(UiElement child)
{ {
if (child.EventId == 0) child.EventId = _nextEventId++; AssignEventIds(child);
base.AddChild(child); base.AddChild(child);
} }
private void AssignEventIds(UiElement element)
{
if (element.EventId == 0)
element.EventId = _nextEventId++;
foreach (var child in element.Children)
AssignEventIds(child);
}
private static void BroadcastGlobalUiTime(UiElement element, double nowSeconds)
{
if (element is IUiGlobalTimeListener listener)
listener.OnGlobalUiTime(nowSeconds);
// A listener may synchronously close/remove a window. Snapshot the walk,
// then skip children no longer owned by this parent so a deleted subtree
// cannot receive a stale pulse and collection mutation cannot invalidate it.
foreach (var child in element.Children.ToArray())
if (ReferenceEquals(child.Parent, element))
BroadcastGlobalUiTime(child, nowSeconds);
}
internal void OnSubtreeRemoving(UiElement subtree)
{
if (IsWithinSubtree(KeyboardFocus, subtree))
SetKeyboardFocus(null);
if (IsWithinSubtree(Captured, subtree))
{
ReleaseCapture();
_dragCandidate = false;
}
if (IsWithinSubtree(DefaultTextInput, subtree))
DefaultTextInput = null;
if (IsWithinSubtree(Modal, subtree))
Modal = null;
if (IsWithinSubtree(DragSource, subtree))
{
DragSource = null;
DragPayload = null;
_dragGhost = null;
_dragCandidate = false;
}
if (IsWithinSubtree(_hoverWidget, subtree))
{
var leave = new UiEvent(_hoverWidget!.EventId, _hoverWidget, UiEventType.HoverLeave);
_hoverWidget.OnEvent(in leave);
_hoverWidget = null;
_tooltipFired = false;
}
if (IsWithinSubtree(_lastDragHoverTarget, subtree))
_lastDragHoverTarget = null;
if (IsWithinSubtree(_lastClickTarget, subtree))
_lastClickTarget = null;
if (IsWithinSubtree(_windowDragTarget, subtree))
{
_windowDragTarget = null;
_dragCandidate = false;
}
if (IsWithinSubtree(_resizeTarget, subtree))
{
_resizeTarget = null;
_dragCandidate = false;
}
foreach (string name in _windows
.Where(pair => IsWithinSubtree(pair.Value, subtree))
.Select(pair => pair.Key)
.ToArray())
{
_windows.Remove(name);
}
}
private static bool IsWithinSubtree(UiElement? element, UiElement subtree)
{
while (element is not null)
{
if (ReferenceEquals(element, subtree)) return true;
element = element.Parent;
}
return false;
}
// ── Per-frame pumping ──────────────────────────────────────────────── // ── Per-frame pumping ────────────────────────────────────────────────
public void Tick(double dt, long nowMs) public void Tick(double dt, long nowMs)
@ -170,8 +245,18 @@ public sealed class UiRoot : UiElement
var e = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.Tooltip); var e = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.Tooltip);
_hoverWidget.OnEvent(in e); _hoverWidget.OnEvent(in e);
_tooltipFired = true; _tooltipFired = true;
_tooltipShownMs = _nowMs;
}
else if (_hoverWidget is not null && _tooltipFired
&& _nowMs - _tooltipShownMs >= TooltipDurationMs)
{
var leave = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.HoverLeave);
_hoverWidget.OnEvent(in leave);
_hoverWidget = null;
_tooltipFired = false;
} }
BroadcastGlobalUiTime(this, nowMs / 1000d);
TickSelfAndChildren(dt); TickSelfAndChildren(dt);
} }
@ -550,7 +635,13 @@ public sealed class UiRoot : UiElement
} }
public void SetCapture(UiElement e) => Captured = e; public void SetCapture(UiElement e) => Captured = e;
public void ReleaseCapture() => Captured = null; public void ReleaseCapture()
{
Captured = null;
// Retail restarts the tooltip idle deadline when capture is released.
_hoverStartedMs = _nowMs;
_tooltipFired = false;
}
// ── Window manager (named top-level windows: Show / Hide / Toggle) ─── // ── Window manager (named top-level windows: Show / Hide / Toggle) ───
@ -694,14 +785,6 @@ public sealed class UiRoot : UiElement
target.OnEvent(in e); target.OnEvent(in e);
} }
public void RegisterTimerEvent(int type, UiElement target, int delayMs,
object? payload = null)
{
_timers.Add((_nowMs + delayMs, new UiEvent(target.EventId, target, type, Payload: payload)));
}
private readonly List<(long fireAt, UiEvent e)> _timers = new();
private void UpdateButtonFlag(UiMouseButton b, bool down) private void UpdateButtonFlag(UiMouseButton b, bool down)
{ {
switch (b) switch (b)

View file

@ -15,10 +15,9 @@ namespace AcDream.App.UI;
/// text inside the window. /// text inside the window.
/// ///
/// <para> /// <para>
/// Supports Windows-like text selection: a left-click-drag inside the transcript /// When retail Selectable property `0x27` is enabled, left-click-drag selects
/// selects characters (the <see cref="UiElement.CapturesPointerDrag"/> opt-out /// characters, Ctrl+C copies the selected span, and Ctrl+A selects everything.
/// stops that interior drag from moving the host window), and Ctrl+C copies the /// Display-only text remains click-through and cannot steal focus or window drag.
/// selected span to the clipboard. Ctrl+A selects everything.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class UiText : UiElement public sealed class UiText : UiElement
@ -80,6 +79,37 @@ public sealed class UiText : UiElement
/// <summary>Inner text inset from the view edges, px.</summary> /// <summary>Inner text inset from the view edges, px.</summary>
public float Padding { get; set; } = 4f; public float Padding { get; set; } = 4f;
/// <summary>Retail property 0x20. Independent of horizontal/vertical
/// justification; false permits the normal multi-line layout path.</summary>
public bool OneLine { get; set; }
private bool _selectable;
/// <summary>
/// Retail property 0x27. A display-only text element does not claim the mouse,
/// focus, or pointer drag. Selection and clipboard behavior are enabled only
/// when this capability is true.
/// </summary>
public bool Selectable
{
get => _selectable;
set
{
if (_selectable == value) return;
_selectable = value;
ClickThrough = !value;
AcceptsFocus = value;
IsEditControl = value;
CapturesPointerDrag = value;
if (!value)
{
_selecting = false;
_selAnchor = null;
_selCaret = null;
}
}
}
/// <summary>Static centered single-line mode (retail <c>UIElement_Text</c> center /// <summary>Static centered single-line mode (retail <c>UIElement_Text</c> center
/// justification): draws the FIRST line centered horizontally AND vertically in the /// justification): draws the FIRST line centered horizontally AND vertically in the
/// element rect, with NO scroll/selection machinery. Used for static labels such as /// element rect, with NO scroll/selection machinery. Used for static labels such as
@ -135,9 +165,11 @@ public sealed class UiText : UiElement
public UiText() public UiText()
{ {
AcceptsFocus = true; // UIElement_Text starts display-only (m_bitField = DIRTY|CURSOR_VISIBLE).
IsEditControl = true; // absorb keys (Ctrl+C) while focused ClickThrough = true;
CapturesPointerDrag = true; // interior drag selects, doesn't move the window AcceptsFocus = false;
IsEditControl = false;
CapturesPointerDrag = false;
} }
/// <summary>The text view draws its own lines + background; any dat sub-elements /// <summary>The text view draws its own lines + background; any dat sub-elements
@ -174,7 +206,7 @@ public sealed class UiText : UiElement
// Static centered single-line mode (vitals cur/max numbers etc.): draw the first // Static centered single-line mode (vitals cur/max numbers etc.): draw the first
// line centered H+V (or H+Top/Bottom per VerticalJustify) with the SAME formula // line centered H+V (or H+Top/Bottom per VerticalJustify) with the SAME formula
// UIElement_Meter used for its label, then skip the scroll/selection machinery entirely. // UIElement_Meter used for its label, then skip the scroll/selection machinery entirely.
if (Centered) if (OneLine && Centered)
{ {
var cLines = LinesProvider(); var cLines = LinesProvider();
if (cLines.Count == 0) return; if (cLines.Count == 0) return;
@ -196,7 +228,7 @@ public sealed class UiText : UiElement
// Static right-aligned single-line mode: draw the first line flush with the right // Static right-aligned single-line mode: draw the first line flush with the right
// edge, vertical position per VerticalJustify, then skip the scroll/selection machinery. // edge, vertical position per VerticalJustify, then skip the scroll/selection machinery.
if (RightAligned) if (OneLine && RightAligned)
{ {
var rLines = LinesProvider(); var rLines = LinesProvider();
if (rLines.Count == 0) return; if (rLines.Count == 0) return;
@ -216,6 +248,26 @@ public sealed class UiText : UiElement
return; return;
} }
// One-line is an independent text-layout property. Left justification uses
// the same vertical policy without turning alignment into a line-count flag.
if (OneLine)
{
var singleLines = LinesProvider();
if (singleLines.Count == 0) return;
var line0 = singleLines[0];
if (DatFont is { } datSingle)
{
float y = VOffset(Height, datSingle.LineHeight, Padding, VerticalJustify);
ctx.DrawStringDat(datSingle, line0.Text, Padding, y, line0.Color);
}
else if ((Font ?? ctx.DefaultFont) is { } bitmapSingle)
{
float y = VOffset(Height, bitmapSingle.LineHeight, Padding, VerticalJustify);
ctx.DrawString(line0.Text, Padding, y, line0.Color, bitmapSingle);
}
return;
}
// Prefer the retail dat font when set; fall back to BitmapFont. // Prefer the retail dat font when set; fall back to BitmapFont.
var datFont = DatFont; var datFont = DatFont;
var bitmapFont = datFont is null ? (Font ?? ctx.DefaultFont) : null; var bitmapFont = datFont is null ? (Font ?? ctx.DefaultFont) : null;
@ -301,6 +353,7 @@ public sealed class UiText : UiElement
{ {
case UiEventType.Scroll: case UiEventType.Scroll:
{ {
if (!Selectable) return false;
// Silk wheel +Y = scroll up = reveal older = toward the TOP = decrease ScrollY. // Silk wheel +Y = scroll up = reveal older = toward the TOP = decrease ScrollY.
// ScrollByLines sign: +down/newer, -up/older. // ScrollByLines sign: +down/newer, -up/older.
// e.Data0 > 0 → wheel up → want older → ScrollByLines with negative lines. // e.Data0 > 0 → wheel up → want older → ScrollByLines with negative lines.
@ -311,6 +364,7 @@ public sealed class UiText : UiElement
case UiEventType.MouseDown: case UiEventType.MouseDown:
{ {
if (!Selectable) return false;
// Data1/Data2 = local-to-target coords (UiRoot.OnMouseDown). // Data1/Data2 = local-to-target coords (UiRoot.OnMouseDown).
var p = HitChar(e.Data1, e.Data2); var p = HitChar(e.Data1, e.Data2);
_selAnchor = p; _selAnchor = p;
@ -321,6 +375,7 @@ public sealed class UiText : UiElement
case UiEventType.MouseMove: case UiEventType.MouseMove:
{ {
if (!Selectable) return false;
if (_selecting) if (_selecting)
{ {
// Data1/Data2 = local-to-target coords (DispatchMouseMove). // Data1/Data2 = local-to-target coords (DispatchMouseMove).
@ -332,12 +387,14 @@ public sealed class UiText : UiElement
case UiEventType.MouseUp: case UiEventType.MouseUp:
{ {
if (!Selectable) return false;
_selecting = false; _selecting = false;
return true; return true;
} }
case UiEventType.KeyDown: case UiEventType.KeyDown:
{ {
if (!Selectable) return false;
var key = (Silk.NET.Input.Key)e.Data0; var key = (Silk.NET.Input.Key)e.Data0;
bool ctrl = Keyboard is not null bool ctrl = Keyboard is not null
&& (Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlLeft) && (Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlLeft)

View file

@ -1528,10 +1528,12 @@ public class CharacterStatControllerTests
}; };
} }
/// <summary>Manufacture a minimal UiButton with a fake ElementInfo (no dat sprites).</summary> /// <summary>Manufacture a minimal UiButton with retail normal/ghosted states.</summary>
private static UiButton MakeButton(uint id = 0u) private static UiButton MakeButton(uint id = 0u)
{ {
var info = new ElementInfo { Id = id, Type = 1 }; var info = new ElementInfo { Id = id, Type = 1 };
info.StateMedia["Normal"] = (1u, 1);
info.StateMedia["Ghosted"] = (2u, 1);
return new UiButton(info, static _ => (0u, 0, 0)); return new UiButton(info, static _ => (0u, 0, 0));
} }

View file

@ -1,3 +1,4 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout; using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout; namespace AcDream.App.Tests.UI.Layout;
@ -43,4 +44,18 @@ public class ChatLayoutConformanceTests
Assert.Equal(12u, Find(root, 0x10000011u)!.Type); // Text/style-prototype (transcript) Assert.Equal(12u, Find(root, 0x10000011u)!.Type); // Text/style-prototype (transcript)
Assert.Equal(12u, Find(root, 0x10000016u)!.Type); // Text/style-prototype (input) Assert.Equal(12u, Find(root, 0x10000016u)!.Type); // Text/style-prototype (input)
} }
[Fact]
public void ChatFixture_BuildsSelectableTranscriptAndEditableInputInPlace()
{
var layout = FixtureLoader.LoadChat();
var transcript = Assert.IsType<UiText>(layout.FindElement(0x10000011u));
var input = Assert.IsType<UiField>(layout.FindElement(0x10000016u));
Assert.True(transcript.Selectable);
Assert.True(input.Selectable);
Assert.True(input.OneLine);
Assert.Equal(0x10000016u, input.DatElementId);
}
} }

View file

@ -15,9 +15,8 @@ namespace AcDream.App.Tests.UI.Layout;
/// real chat layout hierarchy (root → transcript panel + input bar as Type-3 /// real chat layout hierarchy (root → transcript panel + input bar as Type-3
/// containers, with Type-12 children for transcript + input, plus a Type-3 track /// containers, with Type-12 children for transcript + input, plus a Type-3 track
/// and menu), call <see cref="LayoutImporter.Build"/> to get the widget tree /// and menu), call <see cref="LayoutImporter.Build"/> to get the widget tree
/// (Type-12 children skipped, Type-3 parents created), then call /// (Type-12 children become property-driven text/field widgets), then call
/// <see cref="ChatWindowController.Bind"/> which reads rects from the info tree /// <see cref="ChatWindowController.Bind"/> which binds those widgets in place.
/// and places behavioral widgets under the parent containers.
/// </summary> /// </summary>
public class ChatWindowControllerTests public class ChatWindowControllerTests
{ {
@ -42,7 +41,7 @@ public class ChatWindowControllerTests
/// track (Type-3) [0x10000012] ← Type-3 in test (not Type-11); Bind skips scrollbar bind /// track (Type-3) [0x10000012] ← Type-3 in test (not Type-11); Bind skips scrollbar bind
/// inputBar (Type-3) [0x10000013] /// inputBar (Type-3) [0x10000013]
/// menu (Type-6) [0x10000014] /// menu (Type-6) [0x10000014]
/// input (Type-12, no media) [0x10000016] ← built as UiText by factory; Bind removes + replaces with UiField /// input (Type-12, Editable+Selectable) [0x10000016] ← built as UiField
/// send (Type-3) [0x10000019] /// send (Type-3) [0x10000019]
/// maxmin (Type-3) [0x1000046F] /// maxmin (Type-3) [0x1000046F]
/// </summary> /// </summary>
@ -71,9 +70,26 @@ public class ChatWindowControllerTests
}; };
var inputNode = new ElementInfo var inputNode = new ElementInfo
{ {
Id = 0x10000016u, Type = 12, // Type-12, no media → skipped by factory Id = 0x10000016u, Type = 12,
X = 46, Y = 0, Width = 398, Height = 17, X = 46, Y = 0, Width = 398, Height = 17,
}; };
var inputState = new UiStateInfo { Id = UiStateInfo.DirectStateId };
inputState.Properties.Values[0x16u] = new UiPropertyValue
{
Kind = UiPropertyKind.Bool,
BoolValue = true,
};
inputState.Properties.Values[0x20u] = new UiPropertyValue
{
Kind = UiPropertyKind.Bool,
BoolValue = true,
};
inputState.Properties.Values[0x27u] = new UiPropertyValue
{
Kind = UiPropertyKind.Bool,
BoolValue = true,
};
inputNode.States[UiStateInfo.DirectStateId] = inputState;
var sendNode = new ElementInfo var sendNode = new ElementInfo
{ {
Id = 0x10000019u, Type = 3, X = 444, Y = 0, Width = 46, Height = 17, Id = 0x10000019u, Type = 3, X = 444, Y = 0, Width = 46, Height = 17,

View file

@ -42,7 +42,42 @@ public class DatWidgetFactoryTests
public void Type12_Text_MakesUiText() public void Type12_Text_MakesUiText()
{ {
var e = DatWidgetFactory.Create(new ElementInfo { Type = 12, Width = 100, Height = 40 }, NoTex, null); var e = DatWidgetFactory.Create(new ElementInfo { Type = 12, Width = 100, Height = 40 }, NoTex, null);
Assert.IsType<UiText>(e); var text = Assert.IsType<UiText>(e);
Assert.False(text.Selectable);
Assert.True(text.ClickThrough);
Assert.False(text.AcceptsFocus);
Assert.False(text.CapturesPointerDrag);
}
[Fact]
public void Type12_EditableProperty_MakesUiFieldInPlace()
{
var info = TextInfo(
(0x16u, Bool(true)),
(0x20u, Bool(true)),
(0x27u, Bool(true)),
(0x1Eu, Integer(80)));
var field = Assert.IsType<UiField>(DatWidgetFactory.Create(info, NoTex, null));
Assert.Equal(info.Id, field.ElementId);
Assert.True(field.OneLine);
Assert.True(field.Selectable);
Assert.Equal(80, field.MaxCharacters);
}
[Fact]
public void Type12_SelectableProperty_MakesSelectableUiText()
{
var text = Assert.IsType<UiText>(DatWidgetFactory.Create(
TextInfo((0x27u, Bool(true))),
NoTex,
null));
Assert.True(text.Selectable);
Assert.False(text.ClickThrough);
Assert.True(text.AcceptsFocus);
Assert.True(text.CapturesPointerDrag);
} }
// ── Test 4: Rect + anchors set from ElementInfo ─────────────────────────── // ── Test 4: Rect + anchors set from ElementInfo ───────────────────────────
@ -74,6 +109,46 @@ public class DatWidgetFactoryTests
Assert.Equal(AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, e.Anchors); Assert.Equal(AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, e.Anchors);
} }
[Fact]
public void ImportedDescendant_PreservesExactRawEdgePolicy()
{
var info = new ElementInfo
{
Type = 3,
X = 10,
Y = 20,
Width = 30,
Height = 40,
Left = 4,
Top = 3,
Right = 1,
Bottom = 0,
OriginalParentWidth = 100,
OriginalParentHeight = 200,
HasOriginalParentSize = true,
};
var element = DatWidgetFactory.Create(info, NoTex, null)!;
var policy = Assert.IsType<UiLayoutPolicy>(element.LayoutPolicy);
Assert.Equal(4u, policy.LeftMode);
Assert.Equal(3u, policy.TopMode);
Assert.Equal(1u, policy.RightMode);
Assert.Equal(0u, policy.BottomMode);
Assert.Equal(new UiPixelRect(0, 0, 99, 199), policy.OriginalParent);
}
[Fact]
public void ImportedRoot_HasNoRawEdgePolicy()
{
var element = DatWidgetFactory.Create(
new ElementInfo { Type = 3, Width = 100, Height = 200 },
NoTex,
null)!;
Assert.Null(element.LayoutPolicy);
}
[Fact] [Fact]
public void Create_PropagatesStateCursors() public void Create_PropagatesStateCursors()
{ {
@ -339,6 +414,22 @@ public class DatWidgetFactoryTests
Assert.Equal(gold, t.DefaultColor); Assert.Equal(gold, t.DefaultColor);
} }
private static ElementInfo TextInfo(params (uint Id, UiPropertyValue Value)[] properties)
{
var info = new ElementInfo { Id = 0x10000016u, Type = 12, Width = 100, Height = 20 };
var state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
foreach (var property in properties)
state.Properties.Values[property.Id] = property.Value;
info.States[UiStateInfo.DirectStateId] = state;
return info;
}
private static UiPropertyValue Bool(bool value)
=> new() { Kind = UiPropertyKind.Bool, BoolValue = value };
private static UiPropertyValue Integer(int value)
=> new() { Kind = UiPropertyKind.Integer, IntegerValue = value };
/// <summary> /// <summary>
/// When ElementInfo.FontColor is null (dat carried no 0x1B), DefaultColor must /// When ElementInfo.FontColor is null (dat carried no 0x1B), DefaultColor must
/// stay at white (Vector4.One) — the backward-compatible default. /// stay at white (Vector4.One) — the backward-compatible default.

View file

@ -65,6 +65,30 @@ public static class FixtureLoader
public static AcDream.App.UI.Layout.ElementInfo LoadRadarInfos() public static AcDream.App.UI.Layout.ElementInfo LoadRadarInfos()
=> LoadInfos("radar_21000074.json"); => LoadInfos("radar_21000074.json");
public static ImportedLayout LoadToolbar()
=> LayoutImporter.Build(LoadToolbarInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadToolbarInfos()
=> LoadInfos("toolbar_21000016.json");
public static ImportedLayout LoadInventory()
=> LayoutImporter.Build(LoadInventoryInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadInventoryInfos()
=> LoadInfos("inventory_21000023.json");
public static ImportedLayout LoadPaperdoll()
=> LayoutImporter.Build(LoadPaperdollInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadPaperdollInfos()
=> LoadInfos("paperdoll_21000024.json");
public static ImportedLayout LoadCharacter()
=> LayoutImporter.Build(LoadCharacterInfos(), _ => (0u, 0, 0), null);
public static ElementInfo LoadCharacterInfos()
=> LoadInfos("character_2100002E.json");
// ── Shared loader ──────────────────────────────────────────────────────── // ── Shared loader ────────────────────────────────────────────────────────
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName) private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)

View file

@ -139,6 +139,36 @@ public class LayoutConformanceTests
Assert.Contains(0x40000000u, fontDids); Assert.Contains(0x40000000u, fontDids);
} }
[Fact]
public void VitalsBinding_ReusesAuthoredTextNodesWithoutDuplicates()
{
var layout = FixtureLoader.LoadVitals();
VitalsController.Bind(
layout,
() => 1f,
() => 1f,
() => 1f,
() => "100/100",
() => "90/100",
() => "80/100");
(uint MeterId, uint TextId)[] cases =
[
(VitalsController.Health, VitalsController.HealthText),
(VitalsController.Stamina, VitalsController.StaminaText),
(VitalsController.Mana, VitalsController.ManaText),
];
foreach (var (meterId, textId) in cases)
{
var meter = Assert.IsType<UiMeter>(layout.FindElement(meterId));
var text = Assert.IsType<UiText>(layout.FindElement(textId));
Assert.Same(meter, text.Parent);
Assert.Single(meter.Children, child => child == text);
Assert.False(text.Selectable);
}
}
private static void CollectFontDids(ElementInfo node, System.Collections.Generic.List<uint> acc) private static void CollectFontDids(ElementInfo node, System.Collections.Generic.List<uint> acc)
{ {
if (node.FontDid != 0) acc.Add(node.FontDid); if (node.FontDid != 0) acc.Add(node.FontDid);

View file

@ -0,0 +1,88 @@
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public sealed class RetailFixtureConformanceTests
{
[Theory]
[MemberData(nameof(LayoutCases))]
public void ProductionFixture_PreservesRootAndCanonicalStates(
Func<ElementInfo> load,
uint rootId,
uint rootType,
float width,
float height,
int minimumNodeCount)
{
var root = load();
Assert.Equal(rootId, root.Id);
Assert.Equal(rootType, root.Type);
Assert.Equal(width, root.Width);
Assert.Equal(height, root.Height);
Assert.True(CountNodes(root) >= minimumNodeCount);
Assert.Contains(UiStateInfo.DirectStateId, root.States.Keys);
Assert.Contains(Enumerate(root), element => element.States.Count > 1);
Assert.Contains(Enumerate(root), element =>
element.States.Values.Any(state => state.Properties.Values.Count > 0));
}
[Fact]
public void ToolbarFixture_ContainsObjectButtonsAndRetailButtonStates()
{
var root = FixtureLoader.LoadToolbarInfos();
var character = Find(root, 0x10000199u);
var inventory = Find(root, 0x100001B1u);
Assert.NotNull(character);
Assert.NotNull(inventory);
Assert.Contains(1u, character.States.Keys); // Normal
Assert.Contains(3u, character.States.Keys); // Normal_pressed
Assert.Contains(6u, character.States.Keys); // Highlight
}
[Fact]
public void InventoryFixture_ContainsMountedBackpackAndPaperdollContent()
{
var root = FixtureLoader.LoadInventoryInfos();
Assert.NotNull(Find(root, InventoryController.ContentsGridId));
Assert.NotNull(Find(root, InventoryController.ContainerListId));
Assert.NotNull(Find(root, InventoryController.BurdenMeterId));
Assert.NotNull(Find(root, 0x100001D5u)); // mounted paperdoll viewport
}
[Fact]
public void CharacterFixture_ContainsAllThreeRuntimePages()
{
var root = FixtureLoader.LoadCharacterInfos();
Assert.NotNull(Find(root, CharacterStatController.AttributesPageId));
Assert.NotNull(Find(root, CharacterStatController.SkillsPageId));
Assert.NotNull(Find(root, CharacterStatController.TitlesPageId));
}
public static TheoryData<Func<ElementInfo>, uint, uint, float, float, int> LayoutCases
=> new()
{
{ FixtureLoader.LoadToolbarInfos, 0x10000191u, 0x10000007u, 300f, 122f, 20 },
{ FixtureLoader.LoadInventoryInfos, 0x100001CCu, 0x10000023u, 300f, 362f, 40 },
{ FixtureLoader.LoadPaperdollInfos, 0x100001D4u, 0x10000024u, 224f, 214f, 20 },
{ FixtureLoader.LoadCharacterInfos, 0x10000227u, 8u, 300f, 600f, 60 },
};
private static int CountNodes(ElementInfo root)
=> Enumerate(root).Count();
private static IEnumerable<ElementInfo> Enumerate(ElementInfo root)
{
yield return root;
foreach (var child in root.Children)
foreach (var descendant in Enumerate(child))
yield return descendant;
}
private static ElementInfo? Find(ElementInfo root, uint id)
=> Enumerate(root).FirstOrDefault(element => element.Id == id);
}

View file

@ -0,0 +1,60 @@
using System.Runtime.CompilerServices;
using System.Text.Json;
using AcDream.App.UI.Layout;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Regenerates every committed retail layout fixture from production portal.dat.
/// The test is inert unless <c>ACDREAM_REGENERATE_UI_FIXTURES=1</c> is set, keeping
/// normal test runs deterministic and dat-independent.
/// </summary>
public sealed class RetailLayoutFixtureGenerator
{
private static readonly (uint Id, string FileName)[] Layouts =
{
(0x21000006u, "chat_21000006.json"),
(0x21000016u, "toolbar_21000016.json"),
(0x21000023u, "inventory_21000023.json"),
(0x21000024u, "paperdoll_21000024.json"),
(0x2100002Eu, "character_2100002E.json"),
(0x2100006Cu, "vitals_2100006C.json"),
(0x21000074u, "radar_21000074.json"),
};
[Fact]
public void RegenerateAllRetailFixtures_WhenExplicitlyRequested()
{
if (!string.Equals(
Environment.GetEnvironmentVariable("ACDREAM_REGENERATE_UI_FIXTURES"),
"1",
StringComparison.Ordinal))
{
return;
}
var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
using var dats = new DatCollection(datDir, DatAccessType.Read);
foreach (var (layoutId, fileName) in Layouts)
{
var info = LayoutImporter.ImportInfos(dats, layoutId);
Assert.NotNull(info);
var json = JsonSerializer.Serialize(info, new JsonSerializerOptions
{
IncludeFields = true,
WriteIndented = true,
});
File.WriteAllText(Path.Combine(FixtureDirectory(), fileName), json);
}
}
private static string FixtureDirectory([CallerFilePath] string thisFile = "")
=> Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures");
}

View file

@ -1,3 +1,4 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout; using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout; namespace AcDream.App.Tests.UI.Layout;
@ -87,4 +88,42 @@ public class UiDatElementTests
// No DefaultStateName, no "Normal" state → ActiveState stays "" (DirectState). // No DefaultStateName, no "Normal" state → ActiveState stays "" (DirectState).
Assert.Equal(0x06007777u, e.ActiveMedia().File); Assert.Equal(0x06007777u, e.ActiveMedia().File);
} }
[Fact]
public void NumericStateBridge_SetsNamedStateWithoutChangingGeometry()
{
var info = new ElementInfo { X = 4, Y = 5, Width = 30, Height = 40 };
info.States[RetailUiStateIds.ShowDetail] = new UiStateInfo
{
Id = RetailUiStateIds.ShowDetail,
Name = "ShowDetail",
Image = new UiImageMedia(0x06000009u, 3),
};
info.StateMedia["ShowDetail"] = (0x06000009u, 3);
var element = new UiDatElement(info, _ => (0u, 0, 0))
{
Left = info.X,
Top = info.Y,
Width = info.Width,
Height = info.Height,
};
Assert.True(element.TrySetRetailState(RetailUiStateIds.ShowDetail));
Assert.Equal(RetailUiStateIds.ShowDetail, element.ActiveRetailStateId);
Assert.Equal((0x06000009u, 3), element.ActiveMedia());
Assert.Equal((4f, 5f, 30f, 40f),
(element.Left, element.Top, element.Width, element.Height));
}
[Fact]
public void NumericStateBridge_MissingStatePreservesCurrentState()
{
var info = new ElementInfo { DefaultStateName = "Normal" };
info.StateMedia["Normal"] = (0x06000001u, 1);
var element = new UiDatElement(info, _ => (0u, 0, 0));
Assert.False(element.TrySetRetailState(RetailUiStateIds.ShowDetail));
Assert.Equal("Normal", element.ActiveState);
}
} }

View file

@ -0,0 +1,138 @@
using System.Numerics;
using AcDream.App.UI.Layout;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.App.Tests.UI.Layout;
public sealed class UiPropertyBagTests
{
[Fact]
public void Merge_UsesKeyPresence_ForExplicitFalseAndZero()
{
var baseBag = new UiPropertyBag();
baseBag.Values[0x16u] = Bool(true);
baseBag.Values[0x14u] = Enum(3u);
var derivedBag = new UiPropertyBag();
derivedBag.Values[0x16u] = Bool(false);
derivedBag.Values[0x14u] = Enum(0u);
var merged = UiPropertyBag.Merge(baseBag, derivedBag);
Assert.False(merged.Values[0x16u].BoolValue);
Assert.Equal(0ul, merged.Values[0x14u].UnsignedValue);
}
[Fact]
public void EffectiveProperty_NamedStateOverridesDirectStateByPresence()
{
var info = new ElementInfo { DefaultStateId = 1u };
info.States[UiStateInfo.DirectStateId] = State(
UiStateInfo.DirectStateId,
"",
(0x16u, Bool(true)));
info.States[1u] = State(1u, "Normal", (0x16u, Bool(false)));
Assert.True(info.TryGetEffectiveProperty(0x16u, out var property));
Assert.False(property.BoolValue);
}
[Fact]
public void ElementMerge_ExplicitCenteredStatePropertyOverridesBaseLeft()
{
var baseInfo = new ElementInfo();
baseInfo.States[UiStateInfo.DirectStateId] = State(
UiStateInfo.DirectStateId,
"",
(0x14u, Enum(0u)));
var derivedInfo = new ElementInfo();
derivedInfo.States[UiStateInfo.DirectStateId] = State(
UiStateInfo.DirectStateId,
"",
(0x14u, Enum(1u)));
var merged = ElementReader.Merge(baseInfo, derivedInfo);
Assert.Equal(HJustify.Center, merged.HJustify);
Assert.Equal(1ul, merged.States[UiStateInfo.DirectStateId]
.Properties.Values[0x14u].UnsignedValue);
}
[Fact]
public void ConvertProperty_PreservesNestedRetailValues()
{
var source = new StructBaseProperty { MasterPropertyId = 0xA0u };
var array = new ArrayBaseProperty { MasterPropertyId = 0xA1u };
array.Value.Add(new IntegerBaseProperty { MasterPropertyId = 0xA2u, Value = -17 });
array.Value.Add(new Bitfield64BaseProperty
{
MasterPropertyId = 0xA3u,
Value = 0xFEDCBA9876543210ul,
});
source.Value[0x55u] = array;
source.Value[0x56u] = new VectorBaseProperty
{
MasterPropertyId = 0xA4u,
Value = new Vector3(1.25f, -2.5f, 9.75f),
};
var converted = LayoutImporter.ConvertProperty(source);
Assert.Equal(UiPropertyKind.Struct, converted.Kind);
Assert.Equal(0xA0u, converted.MasterPropertyId);
var convertedArray = converted.StructValue[0x55u];
Assert.Equal(UiPropertyKind.Array, convertedArray.Kind);
Assert.Equal(0xA1u, convertedArray.MasterPropertyId);
Assert.Equal(-17, convertedArray.ArrayValue[0].IntegerValue);
Assert.Equal(0xFEDCBA9876543210ul, convertedArray.ArrayValue[1].UnsignedValue);
Assert.Equal(new Vector3(1.25f, -2.5f, 9.75f), converted.StructValue[0x56u].VectorValue);
}
[Fact]
public void ConvertProperty_PreservesStringInfoAndColorBytes()
{
var text = new StringInfoBaseProperty
{
Value = new StringInfo
{
Token = 7,
StringId = 0x12345678u,
TableId = 0x23000001u,
Override = StringInfoOverrideFlag.AutoGen,
English = 2,
Comment = 3,
},
};
var color = new ColorBaseProperty
{
Value = new ColorARGB { Blue = 1, Green = 2, Red = 3, Alpha = 4 },
};
var convertedText = LayoutImporter.ConvertProperty(text);
var convertedColor = LayoutImporter.ConvertProperty(color);
Assert.Equal(
new UiStringInfoValue(7, 0x12345678u, 0x23000001u, 2, 2, 3),
convertedText.StringInfoValue);
Assert.Equal(new UiColorValue(1, 2, 3, 4), convertedColor.ColorValue);
}
private static UiPropertyValue Bool(bool value)
=> new() { Kind = UiPropertyKind.Bool, BoolValue = value };
private static UiPropertyValue Enum(uint value)
=> new() { Kind = UiPropertyKind.Enum, UnsignedValue = value };
private static UiStateInfo State(
uint id,
string name,
params (uint Id, UiPropertyValue Value)[] properties)
{
var state = new UiStateInfo { Id = id, Name = name };
foreach (var property in properties)
state.Properties.Values[property.Id] = property.Value;
return state;
}
}

View file

@ -16,7 +16,10 @@ public class VitalsBindingTests
public void Bind_SetsHealthMeterFillFromProvider() public void Bind_SetsHealthMeterFillFromProvider()
{ {
var health = new UiMeter(); var health = new UiMeter();
var layout = FakeLayout((VitalsController.Health, health)); var healthText = new UiText();
var layout = FakeLayout(
(VitalsController.Health, health),
(VitalsController.HealthText, healthText));
float hp = 0.42f; float hp = 0.42f;
VitalsController.Bind(layout, VitalsController.Bind(layout,
@ -28,9 +31,9 @@ public class VitalsBindingTests
manaText: () => ""); manaText: () => "");
Assert.Equal(0.42f, health.Fill()!.Value); Assert.Equal(0.42f, health.Fill()!.Value);
// The meter no longer draws its own label; the cur/max is a centered UiText child. // The meter no longer draws its own label; the authored text node is bound in place.
Assert.Null(health.Label()); Assert.Null(health.Label());
Assert.Equal("42/100", NumberText(health)); Assert.Equal("42/100", NumberText(healthText));
} }
// ── Test 2: All three meters wired to distinct providers ────────────────── // ── Test 2: All three meters wired to distinct providers ──────────────────
@ -41,10 +44,16 @@ public class VitalsBindingTests
var health = new UiMeter(); var health = new UiMeter();
var stamina = new UiMeter(); var stamina = new UiMeter();
var mana = new UiMeter(); var mana = new UiMeter();
var healthText = new UiText();
var staminaText = new UiText();
var manaText = new UiText();
var layout = FakeLayout( var layout = FakeLayout(
(VitalsController.Health, health), (VitalsController.Health, health),
(VitalsController.Stamina, stamina), (VitalsController.Stamina, stamina),
(VitalsController.Mana, mana)); (VitalsController.Mana, mana),
(VitalsController.HealthText, healthText),
(VitalsController.StaminaText, staminaText),
(VitalsController.ManaText, manaText));
VitalsController.Bind(layout, VitalsController.Bind(layout,
healthPct: () => 0.25f, healthPct: () => 0.25f,
@ -56,13 +65,13 @@ public class VitalsBindingTests
// Each meter should reflect its own provider, not another's. // Each meter should reflect its own provider, not another's.
Assert.Equal(0.25f, health.Fill()!.Value); Assert.Equal(0.25f, health.Fill()!.Value);
Assert.Equal("25/100", NumberText(health)); Assert.Equal("25/100", NumberText(healthText));
Assert.Equal(0.50f, stamina.Fill()!.Value); Assert.Equal(0.50f, stamina.Fill()!.Value);
Assert.Equal("50/100", NumberText(stamina)); Assert.Equal("50/100", NumberText(staminaText));
Assert.Equal(0.75f, mana.Fill()!.Value); Assert.Equal(0.75f, mana.Fill()!.Value);
Assert.Equal("75/100", NumberText(mana)); Assert.Equal("75/100", NumberText(manaText));
} }
// ── Test 3: Missing meter ids are silently skipped (no throw) ───────────── // ── Test 3: Missing meter ids are silently skipped (no throw) ─────────────
@ -89,12 +98,12 @@ public class VitalsBindingTests
// ── Helpers ─────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────
/// <summary>The cur/max text from the centered <see cref="UiText"/> number that /// <summary>The cur/max text from the authored <see cref="UiText"/> node.</summary>
/// <see cref="VitalsController"/> attaches as the meter's child.</summary> private static string NumberText(UiText num)
private static string NumberText(UiMeter m)
{ {
var num = Assert.IsType<UiText>(m.Children[0]);
Assert.True(num.Centered); Assert.True(num.Centered);
Assert.True(num.OneLine);
Assert.False(num.Selectable);
var lines = num.LinesProvider(); var lines = num.LinesProvider();
return lines.Count > 0 ? lines[0].Text : ""; return lines.Count > 0 ? lines[0].Text : "";
} }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,39 @@
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
public sealed class UiButtonStateMachineTests
{
public static TheoryData<UiButtonVisualInput, uint> RequestedCases
=> new()
{
{ new(false, false, false, false, false), UiButtonStateMachine.Normal },
{ new(false, false, true, false, true), UiButtonStateMachine.NormalRollover },
{ new(false, false, true, true, false), UiButtonStateMachine.NormalRollover },
{ new(false, false, true, true, true), UiButtonStateMachine.NormalPressed },
{ new(false, true, false, false, false), UiButtonStateMachine.Highlight },
{ new(false, true, true, false, true), UiButtonStateMachine.HighlightRollover },
{ new(false, true, true, true, false), UiButtonStateMachine.HighlightRollover },
{ new(false, true, true, true, true), UiButtonStateMachine.HighlightPressed },
{ new(true, false, true, true, true), UiButtonStateMachine.Ghosted },
{ new(true, true, true, true, true), UiButtonStateMachine.Ghosted },
};
[Theory]
[MemberData(nameof(RequestedCases))]
public void RequestedState_MatchesRetailPrecedence(UiButtonVisualInput input, uint expected)
=> Assert.Equal(expected, UiButtonStateMachine.RequestedState(input));
[Fact]
public void ResolveState_MissingRequestedState_PreservesCustomState()
{
var available = new HashSet<uint> { 0x10000053u };
uint resolved = UiButtonStateMachine.ResolveState(
0x10000053u,
new UiButtonVisualInput(false, false, true, false, true),
available);
Assert.Equal(0x10000053u, resolved);
}
}

View file

@ -22,4 +22,140 @@ public class UiButtonTests
var b = new UiButton(new ElementInfo { Type = 1 }, NoTex); var b = new UiButton(new ElementInfo { Type = 1 }, NoTex);
Assert.False(b.ClickThrough); Assert.False(b.ClickThrough);
} }
[Fact]
public void PointerTransitions_UseRetailNormalStates()
{
var b = ButtonWithStates("Normal", "Normal_rollover", "Normal_pressed");
b.OnEvent(new UiEvent(0, b, UiEventType.HoverEnter));
Assert.Equal("Normal_rollover", b.ActiveState);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
Assert.Equal("Normal_pressed", b.ActiveState);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseMove, Data1: 50, Data2: 50));
Assert.Equal("Normal_rollover", b.ActiveState);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseMove, Data1: 5, Data2: 5));
Assert.Equal("Normal_pressed", b.ActiveState);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseUp, Data1: 5, Data2: 5));
Assert.Equal("Normal_rollover", b.ActiveState);
}
[Fact]
public void ToggleRelease_SelectsHighlightState()
{
var info = ButtonInfo("Normal", "Highlight");
AddBoolProperty(info, 0x0Bu, true);
var b = CreateButton(info);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
b.OnEvent(new UiEvent(0, b, UiEventType.MouseUp, Data1: 5, Data2: 5));
Assert.True(b.Selected);
Assert.Equal("Highlight", b.ActiveState);
}
[Fact]
public void DisabledProperty_SelectsGhostedAndSuppressesClick()
{
var info = ButtonInfo("Normal", "Ghosted");
AddBoolProperty(info, 0x0Du, true);
var b = CreateButton(info);
b.OnClick = () => _clicked = true;
b.OnEvent(new UiEvent(0, b, UiEventType.Click));
Assert.False(b.Enabled);
Assert.Equal("Ghosted", b.ActiveState);
Assert.False(_clicked);
}
[Fact]
public void MissingStandardState_PreservesCustomSemanticState()
{
var info = ButtonInfo("LockedUI");
info.DefaultStateName = "LockedUI";
var b = CreateButton(info);
b.OnEvent(new UiEvent(0, b, UiEventType.HoverEnter));
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
Assert.Equal("LockedUI", b.ActiveState);
}
[Fact]
public void HotClick_FiresImmediatelyRepeatsAndSuppressesReleaseClick()
{
var info = ButtonInfo("Normal");
AddBoolProperty(info, 0x0Fu, true);
AddFloatProperty(info, 0x10u, 0.10f);
AddFloatProperty(info, 0x11u, 0.05f);
int clicks = 0;
var b = CreateButton(info);
b.OnClick = () => clicks++;
b.OnEvent(new UiEvent(0, b, UiEventType.MouseDown, Data1: 5, Data2: 5));
Assert.Equal(1, clicks);
b.OnGlobalUiTime(1.00);
b.OnGlobalUiTime(1.09);
Assert.Equal(1, clicks);
b.OnGlobalUiTime(1.11);
Assert.Equal(2, clicks);
b.OnEvent(new UiEvent(0, b, UiEventType.MouseUp, Data1: 5, Data2: 5));
b.OnEvent(new UiEvent(0, b, UiEventType.Click, Data1: 5, Data2: 5));
Assert.Equal(2, clicks);
}
private static UiButton ButtonWithStates(params string[] states)
{
var info = ButtonInfo(states);
AddBoolProperty(info, 0x13u, true);
return CreateButton(info);
}
private static ElementInfo ButtonInfo(params string[] states)
{
var info = new ElementInfo { Type = 1, Width = 20, Height = 20 };
uint file = 1;
foreach (string state in states)
info.StateMedia[state] = (file++, 1);
if (states.Length > 0)
info.DefaultStateName = states[0];
return info;
}
private static void AddBoolProperty(ElementInfo info, uint id, bool value)
{
if (!info.States.TryGetValue(UiStateInfo.DirectStateId, out var state))
{
state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
info.States[UiStateInfo.DirectStateId] = state;
}
state.Properties.Values[id] = new UiPropertyValue
{
Kind = UiPropertyKind.Bool,
BoolValue = value,
};
}
private static void AddFloatProperty(ElementInfo info, uint id, float value)
{
if (!info.States.TryGetValue(UiStateInfo.DirectStateId, out var state))
{
state = new UiStateInfo { Id = UiStateInfo.DirectStateId };
info.States[UiStateInfo.DirectStateId] = state;
}
state.Properties.Values[id] = new UiPropertyValue
{
Kind = UiPropertyKind.Float,
FloatValue = value,
};
}
private static UiButton CreateButton(ElementInfo info)
=> new(info, NoTex) { Width = info.Width, Height = info.Height };
} }

View file

@ -0,0 +1,132 @@
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
public sealed class UiLayoutPolicyTests
{
private static readonly UiPixelRect OriginalParent =
UiPixelRect.FromPositionAndSize(0, 0, 800, 600);
private static readonly UiPixelRect OriginalChild =
UiPixelRect.FromPositionAndSize(100, 50, 200, 100);
[Fact]
public void ModeZero_PreservesEveryCurrentEdge()
{
var current = new UiPixelRect(111, 66, 333, 177);
var resizedParent = UiPixelRect.FromPositionAndSize(0, 0, 1600, 900);
var result = UiLayoutPolicy.Apply(
0, 0, 0, 0,
OriginalChild,
OriginalParent,
current,
resizedParent);
Assert.Equal(current, result);
}
[Fact]
public void ModeOne_KeepsNearEdgesAndAddsParentDeltaToFarEdges()
{
var resizedParent = UiPixelRect.FromPositionAndSize(0, 0, 1000, 700);
var result = UiLayoutPolicy.Apply(
1, 1, 1, 1,
OriginalChild,
OriginalParent,
OriginalChild,
resizedParent);
Assert.Equal(new UiPixelRect(100, 50, 499, 249), result);
Assert.Equal(400, result.Width);
Assert.Equal(200, result.Height);
}
[Fact]
public void MixedModeTwoAndOne_MovesFixedSizeChildWithFarSide()
{
var resizedParent = UiPixelRect.FromPositionAndSize(0, 0, 1000, 700);
var result = UiLayoutPolicy.Apply(
2, 2, 1, 1,
OriginalChild,
OriginalParent,
OriginalChild,
resizedParent);
Assert.Equal(new UiPixelRect(300, 150, 499, 249), result);
Assert.Equal(OriginalChild.Width, result.Width);
Assert.Equal(OriginalChild.Height, result.Height);
}
[Fact]
public void ModeThree_UsesInclusivePixelCenterFormula()
{
var resizedParent = UiPixelRect.FromPositionAndSize(0, 0, 1001, 701);
var result = UiLayoutPolicy.Apply(
3, 3, 3, 3,
OriginalChild,
OriginalParent,
OriginalChild,
resizedParent);
Assert.Equal(new UiPixelRect(400, 300, 599, 399), result);
}
[Fact]
public void ModeFour_ScalesEachCoordinateWithTruncationTowardZero()
{
var originalParent = UiPixelRect.FromPositionAndSize(0, 0, 10, 10);
var originalChild = new UiPixelRect(-3, -3, -1, -1);
var resizedParent = UiPixelRect.FromPositionAndSize(0, 0, 15, 15);
var result = UiLayoutPolicy.Apply(
4, 4, 4, 4,
originalChild,
originalParent,
originalChild,
resizedParent);
Assert.Equal(new UiPixelRect(-4, -4, -1, -1), result);
}
[Fact]
public void AssigningCompatibilityAnchors_OptsOutOfImportedPolicy()
{
var element = new UiPanel
{
LayoutPolicy = new UiLayoutPolicy(
1, 1, 1, 1,
OriginalChild,
OriginalParent),
};
element.Anchors = AnchorEdges.Left | AnchorEdges.Top;
Assert.Null(element.LayoutPolicy);
}
[Fact]
public void ApplyAnchor_UsesImportedPolicyInsteadOfCompatibilityMargins()
{
var element = new UiPanel
{
Left = 100,
Top = 50,
Width = 200,
Height = 100,
LayoutPolicy = new UiLayoutPolicy(
1, 1, 1, 1,
OriginalChild,
OriginalParent),
};
element.ApplyAnchor(1000, 700);
Assert.Equal(100f, element.Left);
Assert.Equal(50f, element.Top);
Assert.Equal(400f, element.Width);
Assert.Equal(200f, element.Height);
}
}

View file

@ -482,4 +482,99 @@ public class UiRootInputTests
Assert.True(clicked.ZOrder > peer.ZOrder); Assert.True(clicked.ZOrder > peer.ZOrder);
root.OnMouseUp(UiMouseButton.Left, 50, 50); 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();
}
} }