feat(ui): port retained widget foundations
This commit is contained in:
parent
44f9ec13d9
commit
d825572e31
44 changed files with 84813 additions and 292 deletions
63
src/AcDream.App/UI/IUiDatStateful.cs
Normal file
63
src/AcDream.App/UI/IUiDatStateful.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
10
src/AcDream.App/UI/IUiGlobalTimeListener.cs
Normal file
10
src/AcDream.App/UI/IUiGlobalTimeListener.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -138,8 +138,6 @@ public static class CharacterStatController
|
|||
// 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 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 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;
|
||||
xpValue.ClickThrough = true;
|
||||
xpValue.RightAligned = true;
|
||||
xpValue.OneLine = true;
|
||||
xpValue.Padding = 0f; // avoid scroll clip
|
||||
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 affordable10 = cost10 > 0 && sheet.UnassignedXp >= cost10;
|
||||
|
||||
// State "Normal" = affordable/green; "Ghosted" = too expensive or maxed.
|
||||
string btn1State = affordable1 ? StateNormal : StateGhosted;
|
||||
string btn10State = affordable10 ? StateNormal : StateGhosted;
|
||||
|
||||
foreach (var b in allRaise1) { b.Visible = true; b.ActiveState = btn1State; }
|
||||
foreach (var b in allRaise10) { b.Visible = true; b.ActiveState = btn10State; }
|
||||
foreach (var b in allRaise1)
|
||||
{
|
||||
b.Visible = true;
|
||||
b.TrySetRetailState(affordable1
|
||||
? UiButtonStateMachine.Normal
|
||||
: UiButtonStateMachine.Ghosted);
|
||||
}
|
||||
foreach (var b in allRaise10)
|
||||
{
|
||||
b.Visible = true;
|
||||
b.TrySetRetailState(affordable10
|
||||
? UiButtonStateMachine.Normal
|
||||
: UiButtonStateMachine.Ghosted);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RefreshSkillRaiseButtons(
|
||||
|
|
@ -1079,12 +1086,12 @@ public static class CharacterStatController
|
|||
bool affordable = trained
|
||||
? cost > 0 && sheet.UnassignedXp >= cost
|
||||
: cost > 0 && sheet.SkillCredits >= cost;
|
||||
string state = affordable ? StateNormal : StateGhosted;
|
||||
|
||||
foreach (var b in allRaise1)
|
||||
{
|
||||
b.Visible = true;
|
||||
b.ActiveState = state;
|
||||
b.TrySetRetailState(affordable
|
||||
? UiButtonStateMachine.Normal
|
||||
: UiButtonStateMachine.Ghosted);
|
||||
}
|
||||
|
||||
foreach (var b in allRaise10)
|
||||
|
|
@ -1094,7 +1101,9 @@ public static class CharacterStatController
|
|||
{
|
||||
long cost10 = selectedSkill.Raise10Cost;
|
||||
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,
|
||||
ClickThrough = true,
|
||||
RightAligned = true,
|
||||
OneLine = true,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top,
|
||||
};
|
||||
var capturedProvider = valueProvider;
|
||||
|
|
@ -1435,6 +1445,7 @@ public static class CharacterStatController
|
|||
// BackgroundSprite cleared: its full-height sprite would cover line-1/line-2.
|
||||
titleEl.BackgroundSprite = 0;
|
||||
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.
|
||||
// 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.
|
||||
if (datFont is not null) t.DatFont = datFont;
|
||||
t.Centered = true;
|
||||
t.OneLine = true;
|
||||
t.ClickThrough = true;
|
||||
t.LinesProvider = () => new[] { new UiText.Line(text(), color) };
|
||||
}
|
||||
|
|
@ -1829,6 +1841,7 @@ public static class CharacterStatController
|
|||
DatFont = datFont,
|
||||
ClickThrough = true,
|
||||
Centered = true,
|
||||
OneLine = true,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top,
|
||||
};
|
||||
labelEl.LinesProvider = () => new[] { new UiText.Line(capturedLabel, labelColor) };
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ namespace AcDream.App.UI.Layout;
|
|||
/// <para>
|
||||
/// 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
|
||||
/// (<c>0x10000016</c>) is also Type-12, so the factory builds it as an invisible
|
||||
/// <see cref="UiText"/> placeholder; this controller removes that placeholder and adds
|
||||
/// a <see cref="UiField"/> at the same rect. The scrollbar track (<c>0x10000012</c>) is
|
||||
/// (<c>0x10000016</c>) carries Editable property 0x16, so the factory builds it
|
||||
/// directly as <see cref="UiField"/> and this controller binds it in place. The
|
||||
/// scrollbar track (<c>0x10000012</c>) is
|
||||
/// 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)
|
||||
/// and bound in place.
|
||||
|
|
@ -36,7 +36,7 @@ public sealed class ChatWindowController
|
|||
private const uint TrackId = 0x10000012u;
|
||||
private const uint InputBarId = 0x10000013u;
|
||||
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 MaxMinId = 0x1000046Fu;
|
||||
|
||||
|
|
@ -48,9 +48,6 @@ public sealed class ChatWindowController
|
|||
private const uint UpSprite = 0x06004C6Cu; // up arrow (top 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).
|
||||
private const uint MenuNormal = 0x06004D65u; // button face
|
||||
private const uint MenuPressed = 0x06004D66u; // button pressed
|
||||
|
|
@ -159,20 +156,16 @@ public sealed class ChatWindowController
|
|||
BitmapFont? debugFont,
|
||||
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.
|
||||
var transcriptPanel = layout.FindElement(TranscriptPanelId);
|
||||
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(
|
||||
$"[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}) — " +
|
||||
$"chat window will not be interactive.");
|
||||
return null;
|
||||
|
|
@ -215,29 +208,19 @@ public sealed class ChatWindowController
|
|||
// caches layout for mouse selection and Ctrl+C.
|
||||
c.Transcript.Centered = 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.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont);
|
||||
|
||||
// ── Input ────────────────────────────────────────────────────────
|
||||
// The input element (0x10000016) resolves to Type-12 Text, so the factory built it
|
||||
// as an unbound (invisible) UiText placeholder in the input bar. The editable entry
|
||||
// is a controller-placed UiField at the same rect — drop the placeholder, add the field.
|
||||
if (layout.FindElement(InputId) is { Parent: { } inParent } inputPlaceholder)
|
||||
inParent.RemoveChild(inputPlaceholder);
|
||||
c.Input = new UiField
|
||||
{
|
||||
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);
|
||||
// Editable/selectable/one-line semantics and state sprites came from the
|
||||
// imported property/state bags. The controller supplies runtime services only.
|
||||
c.Input = input;
|
||||
c.Input.DatFont = datFont;
|
||||
c.Input.Font = debugFont;
|
||||
c.Input.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f);
|
||||
c.Input.SpriteResolve = resolve;
|
||||
c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);
|
||||
|
||||
// ── Scrollbar — bind the factory-built Type-11 track element ────────
|
||||
|
|
@ -360,21 +343,6 @@ public sealed class ChatWindowController
|
|||
|
||||
// ── 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>
|
||||
/// Convert the ChatVM's detailed lines to the transcript's
|
||||
/// <see cref="UiText.Line"/> record format, applying retail-faithful
|
||||
|
|
|
|||
|
|
@ -10,11 +10,10 @@ namespace AcDream.App.UI.Layout;
|
|||
/// <see cref="UiDatElement"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// Type 12 = UIElement_Text — a scrollable colored-line text view. Every Type-12
|
||||
/// element is now built as a <see cref="UiText"/>. Elements that carry their own
|
||||
/// dat sprite media keep it as the <see cref="UiText.BackgroundSprite"/>. Pure
|
||||
/// prototype elements (no state media, no controller binding) draw nothing because
|
||||
/// <see cref="UiText.BackgroundColor"/> defaults to transparent.
|
||||
/// Type 12 = UIElement_Text. Editable `0x16` elements become <see cref="UiField"/>
|
||||
/// in place; other elements become display/selectable <see cref="UiText"/> widgets.
|
||||
/// Elements that carry their own DAT sprite media keep it as widget background art.
|
||||
/// Pure prototype elements draw nothing because text backgrounds default transparent.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
|
|
@ -102,9 +101,31 @@ public static class DatWidgetFactory
|
|||
e.ZOrder = (int)info.ReadOrder - (int)info.ZLevel * 10000;
|
||||
|
||||
// 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);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
|
@ -253,7 +274,8 @@ public static class DatWidgetFactory
|
|||
|
||||
// ── 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 —
|
||||
/// 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
|
||||
|
|
@ -273,7 +295,7 @@ public static class DatWidgetFactory
|
|||
/// font; otherwise it is the shared global fallback. Controllers that call
|
||||
/// <see cref="ImportedLayout.FindElement"/> and set <see cref="UiText.DatFont"/> afterward
|
||||
/// 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)
|
||||
{
|
||||
uint bg = info.StateMedia.TryGetValue(
|
||||
|
|
@ -281,6 +303,35 @@ public static class DatWidgetFactory
|
|||
: info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m)
|
||||
? 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.
|
||||
// Controllers that call FindElement and set Centered/RightAligned/VerticalJustify
|
||||
// afterward will override these — this is only the dat-driven default.
|
||||
|
|
@ -301,6 +352,8 @@ public static class DatWidgetFactory
|
|||
Centered = centered,
|
||||
RightAligned = rightAligned,
|
||||
VerticalJustify = vJustify,
|
||||
OneLine = oneLine,
|
||||
Selectable = selectable,
|
||||
// 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
|
||||
// it is the shared global fallback. Controllers that call FindElement and explicitly
|
||||
|
|
|
|||
|
|
@ -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"/>
|
||||
/// operate on this type so they can be unit-tested without the dats or OpenGL.
|
||||
///
|
||||
/// IMPORTANT: Tasks 3–6 depend on this shape exactly. Do not add members without
|
||||
/// updating the plan spec and downstream consumers.
|
||||
/// This is the canonical importer/factory seam. Add fields only with matching
|
||||
/// inheritance, fixture-serialization, and conformance coverage.
|
||||
/// </summary>
|
||||
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>
|
||||
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>
|
||||
/// 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>).
|
||||
/// Values 0–4; map to <see cref="AnchorEdges"/> bit-flags via
|
||||
/// <see cref="ElementReader.ToAnchors"/>.
|
||||
/// Values 0–4. Imported elements preserve these in <see cref="UiLayoutPolicy"/>;
|
||||
/// <see cref="ElementReader.ToAnchors"/> is only the compatibility projection
|
||||
/// for older programmatic consumers.
|
||||
/// </summary>
|
||||
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>
|
||||
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>
|
||||
/// Font dat object id inherited from the base element's <c>Properties[0x1A]</c>
|
||||
/// (<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.
|
||||
/// </summary>
|
||||
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>
|
||||
|
|
@ -125,12 +220,10 @@ public sealed class ElementInfo
|
|||
/// </summary>
|
||||
public static class ElementReader
|
||||
{
|
||||
/// <summary>Edge-anchor flags → AnchorEdges, per retail UIElement::UpdateForParentSizeChange
|
||||
/// @0x00462640. The far-axis fields drive stretch: RightEdge==1 ⇒ the right edge tracks the
|
||||
/// parent's right edge (stretch); LeftEdge==2 ⇒ a fixed-width element's left tracks the right
|
||||
/// edge (it moves right). ==4 (not present in the vitals layout) = both-sides stretch; ==3 =
|
||||
/// 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>
|
||||
/// <summary>Compatibility projection from raw retail modes to the legacy
|
||||
/// <see cref="AnchorEdges"/> flags. This projection cannot represent centered
|
||||
/// mode 3 or proportional mode 4 exactly. Imported DAT widgets therefore use
|
||||
/// <see cref="UiLayoutPolicy"/>; call this only for legacy/programmatic paths.</summary>
|
||||
/// <param name="left">LeftEdge dat field value (0–4).</param>
|
||||
/// <param name="top">TopEdge dat field value (0–4).</param>
|
||||
/// <param name="right">RightEdge dat field value (0–4).</param>
|
||||
|
|
@ -180,12 +273,9 @@ public static class ElementReader
|
|||
var m = new ElementInfo
|
||||
{
|
||||
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.
|
||||
// For a text element whose base prototype is Type 12 (style prototype), this yields Type 12 —
|
||||
// which DatWidgetFactory skips (returns null). That is intentional for Plan 1: vitals text
|
||||
// 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 wins if non-zero. Layout instances commonly carry Type 0
|
||||
// and inherit the registered widget type (for example Type 12 text) from
|
||||
// their base prototype; DatWidgetFactory then builds that behavioral type.
|
||||
Type = derived.Type != 0 ? derived.Type : base_.Type,
|
||||
X = derived.X,
|
||||
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
|
||||
// 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
|
||||
// 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,
|
||||
Height = derived.Height != 0 ? derived.Height : base_.Height,
|
||||
Left = derived.Left,
|
||||
|
|
@ -202,6 +292,7 @@ public static class ElementReader
|
|||
Bottom = derived.Bottom,
|
||||
ReadOrder = derived.ReadOrder,
|
||||
ZLevel = derived.ZLevel != 0 ? derived.ZLevel : base_.ZLevel,
|
||||
DefaultStateId = derived.DefaultStateId != 0 ? derived.DefaultStateId : base_.DefaultStateId,
|
||||
FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid,
|
||||
// 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.
|
||||
|
|
@ -216,7 +307,7 @@ public static class ElementReader
|
|||
DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName,
|
||||
// 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
|
||||
// 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).
|
||||
Children = new List<ElementInfo>(derived.Children),
|
||||
};
|
||||
|
|
@ -227,6 +318,68 @@ public static class ElementReader
|
|||
m.StateCursors = new Dictionary<string, UiCursorMedia>(base_.StateCursors);
|
||||
foreach (var kv in derived.StateCursors)
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -463,6 +463,7 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
if (host is UiText t)
|
||||
{
|
||||
t.Centered = true;
|
||||
t.OneLine = true;
|
||||
t.DatFont = datFont;
|
||||
t.ClickThrough = true;
|
||||
t.AcceptsFocus = false;
|
||||
|
|
@ -483,7 +484,7 @@ public sealed class InventoryController : IItemListDragHandler
|
|||
{
|
||||
Left = 0f, Top = 0f, Width = host.Width, Height = host.Height,
|
||||
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,
|
||||
LinesProvider = () =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -203,9 +203,19 @@ public static class LayoutImporter
|
|||
tops.Add(Resolve(dats, d, new HashSet<(uint, uint)>()));
|
||||
}
|
||||
|
||||
return tops.Count == 1
|
||||
? tops[0]
|
||||
: new ElementInfo { Id = 0, Type = 3, Children = tops };
|
||||
if (tops.Count == 1)
|
||||
return tops[0];
|
||||
|
||||
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>
|
||||
|
|
@ -290,7 +300,11 @@ public static class LayoutImporter
|
|||
// Resolve + attach children. Each child gets a FRESH base-chain set:
|
||||
// the cycle guard is per-element, not shared across siblings.
|
||||
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
|
||||
// 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.)
|
||||
result.ZLevel = self.ZLevel;
|
||||
|
||||
// Sub-layout slot sizing: when a sub-layout is mounted into a slot that is TALLER
|
||||
// (or wider) than the sub-layout's own design height, the cascade of Bottom/Right
|
||||
// anchored elements must be resized to fill the slot. Without this step, the
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Mounted descendants retain the base layout's design parent size. The runtime
|
||||
// UiLayoutPolicy therefore performs the retail parent-size update from the correct
|
||||
// baseline when this slot is larger than the mounted layout.
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
@ -378,17 +359,19 @@ public static class LayoutImporter
|
|||
Bottom = d.BottomEdge,
|
||||
ReadOrder = d.ReadOrder,
|
||||
ZLevel = d.ZLevel,
|
||||
DefaultStateId = (uint)d.DefaultState,
|
||||
DefaultStateName = (defState is "Undef" or "Undefined" or "0") ? "" : defState,
|
||||
};
|
||||
|
||||
// DirectState (unnamed, key "").
|
||||
if (d.StateDesc is not null)
|
||||
ReadState(d.StateDesc, "", info);
|
||||
ReadState(d.StateDesc, UiStateInfo.DirectStateId, "", info);
|
||||
|
||||
// Named states (e.g. UIStateId.HideDetail → "HideDetail").
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -398,14 +381,23 @@ public static class LayoutImporter
|
|||
/// <c>info.StateCursors[name]</c>, and extract the font DID from property 0x1A
|
||||
/// (<c>ArrayBaseProperty → DataIdBaseProperty</c>) if not yet set.
|
||||
/// </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;
|
||||
foreach (var m in sd.Media)
|
||||
{
|
||||
if (!imageRead && m is MediaDescImage img && img.File != 0)
|
||||
{
|
||||
info.StateMedia[name] = (img.File, (int)img.DrawMode);
|
||||
state.Image = new UiImageMedia(img.File, (int)img.DrawMode);
|
||||
imageRead = true;
|
||||
}
|
||||
|
||||
|
|
@ -415,9 +407,17 @@ public static class LayoutImporter
|
|||
cursor.File,
|
||||
checked((int)cursor.XHotspot),
|
||||
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 }.
|
||||
// Format doc §3: "ArrayBaseProperty containing ONE DataIdBaseProperty".
|
||||
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 ───────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -534,59 +610,12 @@ public static class LayoutImporter
|
|||
return null;
|
||||
}
|
||||
|
||||
// ── Sub-layout slot sizing helpers ────────────────────────────────────────
|
||||
// ── Raw-edge layout provenance ────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
private static void SetOriginalParentSize(ElementInfo child, float width, float height)
|
||||
{
|
||||
float oldH = el.Height;
|
||||
float newH = el.Height;
|
||||
float oldY = el.Y;
|
||||
|
||||
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);
|
||||
child.OriginalParentWidth = width;
|
||||
child.OriginalParentHeight = height;
|
||||
child.HasOriginalParentSize = true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,7 +157,8 @@ public sealed class RadarController
|
|||
if (_dragButton is not null)
|
||||
_dragButton.Visible = !snapshot.UiLocked;
|
||||
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
|
||||
// strip and no duplicate text node is needed.
|
||||
importedText.Centered = true;
|
||||
importedText.OneLine = true;
|
||||
importedText.Padding = 0f;
|
||||
importedText.DatFont = datFont ?? importedText.DatFont;
|
||||
importedText.ClickThrough = true;
|
||||
|
|
@ -214,6 +216,7 @@ public sealed class RadarController
|
|||
Height = container.Height,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
|
||||
Centered = true,
|
||||
OneLine = true,
|
||||
Padding = 0f,
|
||||
DatFont = datFont,
|
||||
ClickThrough = true,
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ public sealed class SelectedObjectController
|
|||
Left = 0f, Top = 0f, Width = _name.Width, Height = NameBandHeight,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right,
|
||||
Centered = true,
|
||||
OneLine = true,
|
||||
DatFont = datFont,
|
||||
ClickThrough = true,
|
||||
AcceptsFocus = false,
|
||||
|
|
@ -215,7 +216,7 @@ public sealed class SelectedObjectController
|
|||
if (guid is null)
|
||||
{
|
||||
// Deselect: clear the overlay flash immediately too.
|
||||
SetOverlayState("");
|
||||
SetOverlayState(UiStateInfo.DirectStateId);
|
||||
_flashRemaining = 0;
|
||||
return;
|
||||
}
|
||||
|
|
@ -227,7 +228,9 @@ public sealed class SelectedObjectController
|
|||
|
||||
// ── 3. Selection overlay: brief flash (retail container ObjectSelected
|
||||
// = Pause(0.25s)→Normal). "StackedItemSelected" for stacks. ──────────────
|
||||
SetOverlayState(_stackSize(g) > 1u ? "StackedItemSelected" : "ObjectSelected");
|
||||
SetOverlayState(_stackSize(g) > 1u
|
||||
? RetailUiStateIds.StackedItemSelected
|
||||
: RetailUiStateIds.ObjectSelected);
|
||||
_flashRemaining = FlashSeconds;
|
||||
|
||||
// ── 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;
|
||||
_flashRemaining -= deltaSeconds;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,8 +56,6 @@ public sealed class ToolbarController : IItemListDragHandler
|
|||
|
||||
private const uint CharacterButtonId = 0x10000199u;
|
||||
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 UiElement?[] _combatIndicators = new UiElement?[CombatIndicatorIds.Length];
|
||||
|
|
@ -247,7 +245,9 @@ public sealed class ToolbarController : IItemListDragHandler
|
|||
private static void SetButtonOpen(UiButton? button, bool open)
|
||||
{
|
||||
if (button is not null)
|
||||
button.ActiveState = open ? ButtonOpenState : ButtonClosedState;
|
||||
button.TrySetRetailState(open
|
||||
? UiButtonStateMachine.Highlight
|
||||
: UiButtonStateMachine.Normal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ namespace AcDream.App.UI.Layout;
|
|||
/// <c>GL_REPEAT</c> on both S and T, so vertical tiling is always active.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class UiDatElement : UiElement
|
||||
public sealed class UiDatElement : UiElement, IUiDatStateful
|
||||
{
|
||||
// DrawModeType enum values from DatReaderWriter.Enums.
|
||||
// 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>
|
||||
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 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="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>
|
||||
|
|
|
|||
152
src/AcDream.App/UI/Layout/UiPropertyBag.cs
Normal file
152
src/AcDream.App/UI/Layout/UiPropertyBag.cs
Normal 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),
|
||||
};
|
||||
}
|
||||
|
|
@ -27,6 +27,9 @@ public static class VitalsController
|
|||
public const uint Stamina = 0x100000EC;
|
||||
/// <summary>Dat element id for the Mana meter (0x100000EE).</summary>
|
||||
public const uint Mana = 0x100000EE;
|
||||
public const uint HealthText = 0x100000EB;
|
||||
public const uint StaminaText = 0x100000ED;
|
||||
public const uint ManaText = 0x100000EF;
|
||||
|
||||
/// <summary>
|
||||
/// 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> manaText)
|
||||
{
|
||||
BindMeter(layout, Health, healthPct, healthText);
|
||||
BindMeter(layout, Stamina, staminaPct, staminaText);
|
||||
BindMeter(layout, Mana, manaPct, manaText);
|
||||
BindMeter(layout, Health, HealthText, healthPct, healthText);
|
||||
BindMeter(layout, Stamina, StaminaText, staminaPct, staminaText);
|
||||
BindMeter(layout, Mana, ManaText, manaPct, manaText);
|
||||
}
|
||||
|
||||
/// <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 void BindMeter(
|
||||
ImportedLayout layout, uint id,
|
||||
ImportedLayout layout, uint id, uint textId,
|
||||
Func<float> pct,
|
||||
Func<string> text)
|
||||
{
|
||||
|
|
@ -68,31 +71,22 @@ public static class VitalsController
|
|||
|
||||
m.Fill = () => pct();
|
||||
|
||||
// Retail gmVitalsUI renders the cur/max as a real UIElement_Text centered over the
|
||||
// bar — NOT a meter-internal label. Attach a centered UiText (non-interactive
|
||||
// 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).
|
||||
// Retail gmVitalsUI binds the authored UIElement_Text over each meter. The
|
||||
// importer already built and registered that node; do not synthesize a duplicate.
|
||||
m.Label = () => null;
|
||||
if (layout.FindElement(textId) is not UiText number) return;
|
||||
|
||||
var number = new UiText
|
||||
number.Centered = true;
|
||||
number.RightAligned = false;
|
||||
number.OneLine = true;
|
||||
number.Selectable = false;
|
||||
number.DatFont ??= m.DatFont;
|
||||
number.LinesProvider = () =>
|
||||
{
|
||||
Left = 0f, Top = 0f, Width = m.Width, Height = m.Height,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
|
||||
Centered = true,
|
||||
DatFont = m.DatFont, // the same dat font the meter used for its label
|
||||
ClickThrough = true, // decoration: no focus / selection / drag
|
||||
AcceptsFocus = false,
|
||||
IsEditControl = false,
|
||||
CapturesPointerDrag = false,
|
||||
LinesProvider = () =>
|
||||
{
|
||||
var s = text();
|
||||
return string.IsNullOrEmpty(s)
|
||||
? Array.Empty<UiText.Line>()
|
||||
: new[] { new UiText.Line(s, NumberColor) };
|
||||
},
|
||||
var s = text();
|
||||
return string.IsNullOrEmpty(s)
|
||||
? Array.Empty<UiText.Line>()
|
||||
: new[] { new UiText.Line(s, NumberColor) };
|
||||
};
|
||||
m.AddChild(number);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,10 +30,17 @@ namespace AcDream.App.UI;
|
|||
/// earlier dev-scaffold widget with no dat sprites.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class UiButton : UiElement
|
||||
public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
||||
{
|
||||
private readonly ElementInfo _info;
|
||||
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>
|
||||
public Action? OnClick { get; set; }
|
||||
|
|
@ -57,14 +64,84 @@ public sealed class UiButton : UiElement
|
|||
/// <summary>Label horizontal alignment options.</summary>
|
||||
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>
|
||||
/// Active state name, runtime-settable (e.g. Max/Min toggling Normal ↔ Minimized).
|
||||
/// Matches <see cref="UiDatElement.ActiveState"/>.
|
||||
/// </summary>
|
||||
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 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="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>
|
||||
|
|
@ -74,6 +151,25 @@ public sealed class UiButton : UiElement
|
|||
_resolve = resolve;
|
||||
ClickThrough = false; // buttons are interactive — opt OUT of click-through
|
||||
|
||||
foreach (uint stateId in info.States.Keys)
|
||||
if (stateId != UiStateInfo.DirectStateId)
|
||||
_availableStates.Add(stateId);
|
||||
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:
|
||||
// DefaultStateName wins; else "Normal" if that state has a sprite; else DirectState ("").
|
||||
if (!string.IsNullOrEmpty(info.DefaultStateName))
|
||||
|
|
@ -81,6 +177,9 @@ public sealed class UiButton : UiElement
|
|||
else if (info.StateMedia.ContainsKey("Normal"))
|
||||
ActiveState = "Normal";
|
||||
// else ActiveState stays "" (DirectState)
|
||||
|
||||
Enabled = !disabled;
|
||||
UpdateVisualState();
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; }
|
||||
return false;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
79
src/AcDream.App/UI/UiButtonStateMachine.cs
Normal file
79
src/AcDream.App/UI/UiButtonStateMachine.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -121,7 +121,17 @@ public abstract class UiElement
|
|||
|
||||
// ── State flags ─────────────────────────────────────────────────────
|
||||
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>
|
||||
/// 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
|
||||
/// (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 ──────────────────────────────────────────────────
|
||||
public UiElement? Parent { get; private set; }
|
||||
|
|
@ -223,7 +254,9 @@ public abstract class UiElement
|
|||
|
||||
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;
|
||||
return true;
|
||||
}
|
||||
|
|
@ -271,6 +304,9 @@ public abstract class UiElement
|
|||
/// <summary>Per-frame tick (animations, timers, caret blink).</summary>
|
||||
protected virtual void OnTick(double deltaSeconds) { }
|
||||
|
||||
/// <summary>Called synchronously after <see cref="Enabled"/> changes.</summary>
|
||||
protected virtual void OnEnabledChanged() { }
|
||||
|
||||
/// <summary>
|
||||
/// Custom hit-test override. Default is a rectangle containment
|
||||
/// 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;
|
||||
|
||||
/// <summary>
|
||||
/// Tooltip text for this widget. Retail fires event 0x07 after
|
||||
/// ~1000ms hover, then queries the widget's virtual "GetString"
|
||||
/// Tooltip text for this widget. Retail fires event 0x07 after the configured
|
||||
/// hover delay (0.25 seconds by default), then queries the widget's virtual "GetString"
|
||||
/// (vtable +0x88) to render the tooltip body.
|
||||
/// </summary>
|
||||
public virtual string? GetTooltipText() => null;
|
||||
|
|
@ -418,6 +454,22 @@ public abstract class UiElement
|
|||
/// Called by the parent each frame before drawing children.</summary>
|
||||
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 (!_anchorCaptured)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -39,8 +39,8 @@ public readonly record struct UiEvent(
|
|||
/// Evidence from decompile:
|
||||
/// - 0x01 click — chunk_00470000.c ~11140, chunk_004C0000.c ~9270
|
||||
/// - 0x05/0x06 hover — chunk_00460000.c ~6253
|
||||
/// - 0x07 tooltip — chunk_00460000.c ~6253 (delayed via
|
||||
/// Device::RegisterTimerEvent(7, widget, delayMs))
|
||||
/// - 0x07 tooltip — chunk_00460000.c ~6253 (the UI manager polls the
|
||||
/// hovered element's tooltip deadline before global time)
|
||||
/// - 0x0A scroll — chunk_00470000.c ~11210
|
||||
/// - 0x0E right-click— chunk_004A0000.c ~2674
|
||||
/// - 0x15 drag begin — chunk_004A0000.c ~2707
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ namespace AcDream.App.UI;
|
|||
/// </summary>
|
||||
public sealed class UiField : UiElement
|
||||
{
|
||||
public uint ElementId { get; set; }
|
||||
public UiDatFont? DatFont { get; set; }
|
||||
public AcDream.App.Rendering.BitmapFont? Font { get; set; }
|
||||
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 float Padding { get; set; } = 4f;
|
||||
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).
|
||||
/// 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
|
||||
/// background. Null = fall back to the flat <see cref="BackgroundColor"/> rect.</summary>
|
||||
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
|
||||
/// state, RenderSurface 0x060011AB). 0 = no focus sprite.</summary>
|
||||
public uint FocusFieldSprite { get; set; }
|
||||
|
|
@ -262,7 +267,8 @@ public sealed class UiField : UiElement
|
|||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
// 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.
|
||||
bool lit = _focused && SpriteResolve is not null && FocusFieldSprite != 0;
|
||||
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);
|
||||
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);
|
||||
|
||||
float lh = DatFont?.LineHeight ?? Font?.LineHeight ?? 14f;
|
||||
|
|
@ -366,11 +382,11 @@ public sealed class UiField : UiElement
|
|||
|
||||
case UiEventType.MouseDown:
|
||||
_caret = HitCharX(e.Data1);
|
||||
_selAnchor = _caret; // anchor; a drag will extend, a plain click won't
|
||||
_selecting = true;
|
||||
_selAnchor = Selectable ? _caret : null;
|
||||
_selecting = Selectable;
|
||||
return true;
|
||||
case UiEventType.MouseMove:
|
||||
if (_selecting) _caret = HitCharX(e.Data1);
|
||||
if (Selectable && _selecting) _caret = HitCharX(e.Data1);
|
||||
return true;
|
||||
case UiEventType.MouseUp:
|
||||
_selecting = false;
|
||||
|
|
@ -387,15 +403,15 @@ public sealed class UiField : UiElement
|
|||
{
|
||||
switch (key)
|
||||
{
|
||||
case Silk.NET.Input.Key.A: SelectAll(); return true;
|
||||
case Silk.NET.Input.Key.C: CopySelection(); return true;
|
||||
case Silk.NET.Input.Key.X: CutSelection(); return true;
|
||||
case Silk.NET.Input.Key.A when Selectable: SelectAll(); return true;
|
||||
case Silk.NET.Input.Key.C when Selectable: CopySelection(); return true;
|
||||
case Silk.NET.Input.Key.X when Selectable: CutSelection(); return true;
|
||||
case Silk.NET.Input.Key.V: Paste(); return true;
|
||||
}
|
||||
return true; // swallow other Ctrl combos while typing
|
||||
}
|
||||
|
||||
bool shift = ShiftHeld();
|
||||
bool shift = Selectable && ShiftHeld();
|
||||
switch (key)
|
||||
{
|
||||
case Silk.NET.Input.Key.Enter:
|
||||
|
|
|
|||
151
src/AcDream.App/UI/UiLayoutPolicy.cs
Normal file
151
src/AcDream.App/UI/UiLayoutPolicy.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -9,24 +9,15 @@ namespace AcDream.App.UI;
|
|||
public enum ResizeEdges { None = 0, Left = 1, Right = 2, Top = 4, Bottom = 8 }
|
||||
|
||||
/// <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,
|
||||
/// drag-drop state machine, tooltip timer). Routes Silk.NET input events
|
||||
/// into the widget tree with retail-faithful <see cref="UiEvent"/>
|
||||
/// semantics.
|
||||
///
|
||||
/// Retail analog: the <c>DAT_00837ff4</c> Device object (see
|
||||
/// <c>docs/research/retail-ui/04-input-events.md §2</c>). That object has
|
||||
/// a ~20-slot vtable; the methods we emulate here are:
|
||||
///
|
||||
/// <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>
|
||||
/// Retail analog: <c>UIElementManager::UseTime @ 0x0045CFD0</c>. Tooltip
|
||||
/// deadlines are polled before global time message 3; there is no generic Device
|
||||
/// timer queue in the named client.
|
||||
///
|
||||
/// When no widget consumes an event, the <see cref="WorldMouseFallThrough"/>
|
||||
/// or <see cref="WorldKeyFallThrough"/> event fires so the game world
|
||||
|
|
@ -125,8 +116,10 @@ public sealed class UiRoot : UiElement
|
|||
// Hover / tooltip tracking.
|
||||
private UiElement? _hoverWidget;
|
||||
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 long _tooltipShownMs;
|
||||
|
||||
private long _nowMs;
|
||||
|
||||
|
|
@ -152,10 +145,92 @@ public sealed class UiRoot : UiElement
|
|||
|
||||
public override void AddChild(UiElement child)
|
||||
{
|
||||
if (child.EventId == 0) child.EventId = _nextEventId++;
|
||||
AssignEventIds(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 ────────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
_hoverWidget.OnEvent(in e);
|
||||
_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);
|
||||
}
|
||||
|
||||
|
|
@ -550,7 +635,13 @@ public sealed class UiRoot : UiElement
|
|||
}
|
||||
|
||||
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) ───
|
||||
|
||||
|
|
@ -694,14 +785,6 @@ public sealed class UiRoot : UiElement
|
|||
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)
|
||||
{
|
||||
switch (b)
|
||||
|
|
|
|||
|
|
@ -15,10 +15,9 @@ namespace AcDream.App.UI;
|
|||
/// text inside the window.
|
||||
///
|
||||
/// <para>
|
||||
/// Supports Windows-like text selection: a left-click-drag inside the transcript
|
||||
/// selects characters (the <see cref="UiElement.CapturesPointerDrag"/> opt-out
|
||||
/// stops that interior drag from moving the host window), and Ctrl+C copies the
|
||||
/// selected span to the clipboard. Ctrl+A selects everything.
|
||||
/// When retail Selectable property `0x27` is enabled, left-click-drag selects
|
||||
/// characters, Ctrl+C copies the selected span, and Ctrl+A selects everything.
|
||||
/// Display-only text remains click-through and cannot steal focus or window drag.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class UiText : UiElement
|
||||
|
|
@ -80,6 +79,37 @@ public sealed class UiText : UiElement
|
|||
/// <summary>Inner text inset from the view edges, px.</summary>
|
||||
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
|
||||
/// justification): draws the FIRST line centered horizontally AND vertically in the
|
||||
/// element rect, with NO scroll/selection machinery. Used for static labels such as
|
||||
|
|
@ -135,9 +165,11 @@ public sealed class UiText : UiElement
|
|||
|
||||
public UiText()
|
||||
{
|
||||
AcceptsFocus = true;
|
||||
IsEditControl = true; // absorb keys (Ctrl+C) while focused
|
||||
CapturesPointerDrag = true; // interior drag selects, doesn't move the window
|
||||
// UIElement_Text starts display-only (m_bitField = DIRTY|CURSOR_VISIBLE).
|
||||
ClickThrough = true;
|
||||
AcceptsFocus = false;
|
||||
IsEditControl = false;
|
||||
CapturesPointerDrag = false;
|
||||
}
|
||||
|
||||
/// <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
|
||||
// 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.
|
||||
if (Centered)
|
||||
if (OneLine && Centered)
|
||||
{
|
||||
var cLines = LinesProvider();
|
||||
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
|
||||
// edge, vertical position per VerticalJustify, then skip the scroll/selection machinery.
|
||||
if (RightAligned)
|
||||
if (OneLine && RightAligned)
|
||||
{
|
||||
var rLines = LinesProvider();
|
||||
if (rLines.Count == 0) return;
|
||||
|
|
@ -216,6 +248,26 @@ public sealed class UiText : UiElement
|
|||
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.
|
||||
var datFont = DatFont;
|
||||
var bitmapFont = datFont is null ? (Font ?? ctx.DefaultFont) : null;
|
||||
|
|
@ -301,6 +353,7 @@ public sealed class UiText : UiElement
|
|||
{
|
||||
case UiEventType.Scroll:
|
||||
{
|
||||
if (!Selectable) return false;
|
||||
// Silk wheel +Y = scroll up = reveal older = toward the TOP = decrease ScrollY.
|
||||
// ScrollByLines sign: +down/newer, -up/older.
|
||||
// e.Data0 > 0 → wheel up → want older → ScrollByLines with negative lines.
|
||||
|
|
@ -311,6 +364,7 @@ public sealed class UiText : UiElement
|
|||
|
||||
case UiEventType.MouseDown:
|
||||
{
|
||||
if (!Selectable) return false;
|
||||
// Data1/Data2 = local-to-target coords (UiRoot.OnMouseDown).
|
||||
var p = HitChar(e.Data1, e.Data2);
|
||||
_selAnchor = p;
|
||||
|
|
@ -321,6 +375,7 @@ public sealed class UiText : UiElement
|
|||
|
||||
case UiEventType.MouseMove:
|
||||
{
|
||||
if (!Selectable) return false;
|
||||
if (_selecting)
|
||||
{
|
||||
// Data1/Data2 = local-to-target coords (DispatchMouseMove).
|
||||
|
|
@ -332,12 +387,14 @@ public sealed class UiText : UiElement
|
|||
|
||||
case UiEventType.MouseUp:
|
||||
{
|
||||
if (!Selectable) return false;
|
||||
_selecting = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
case UiEventType.KeyDown:
|
||||
{
|
||||
if (!Selectable) return false;
|
||||
var key = (Silk.NET.Input.Key)e.Data0;
|
||||
bool ctrl = Keyboard is not null
|
||||
&& (Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlLeft)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.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, 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,9 +15,8 @@ namespace AcDream.App.Tests.UI.Layout;
|
|||
/// 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
|
||||
/// and menu), call <see cref="LayoutImporter.Build"/> to get the widget tree
|
||||
/// (Type-12 children skipped, Type-3 parents created), then call
|
||||
/// <see cref="ChatWindowController.Bind"/> which reads rects from the info tree
|
||||
/// and places behavioral widgets under the parent containers.
|
||||
/// (Type-12 children become property-driven text/field widgets), then call
|
||||
/// <see cref="ChatWindowController.Bind"/> which binds those widgets in place.
|
||||
/// </summary>
|
||||
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
|
||||
/// inputBar (Type-3) [0x10000013]
|
||||
/// 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]
|
||||
/// maxmin (Type-3) [0x1000046F]
|
||||
/// </summary>
|
||||
|
|
@ -71,9 +70,26 @@ public class ChatWindowControllerTests
|
|||
};
|
||||
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,
|
||||
};
|
||||
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
|
||||
{
|
||||
Id = 0x10000019u, Type = 3, X = 444, Y = 0, Width = 46, Height = 17,
|
||||
|
|
|
|||
|
|
@ -42,7 +42,42 @@ public class DatWidgetFactoryTests
|
|||
public void Type12_Text_MakesUiText()
|
||||
{
|
||||
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 ───────────────────────────
|
||||
|
|
@ -74,6 +109,46 @@ public class DatWidgetFactoryTests
|
|||
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]
|
||||
public void Create_PropagatesStateCursors()
|
||||
{
|
||||
|
|
@ -339,6 +414,22 @@ public class DatWidgetFactoryTests
|
|||
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>
|
||||
/// When ElementInfo.FontColor is null (dat carried no 0x1B), DefaultColor must
|
||||
/// stay at white (Vector4.One) — the backward-compatible default.
|
||||
|
|
|
|||
|
|
@ -65,6 +65,30 @@ public static class FixtureLoader
|
|||
public static AcDream.App.UI.Layout.ElementInfo LoadRadarInfos()
|
||||
=> 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 ────────────────────────────────────────────────────────
|
||||
|
||||
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)
|
||||
|
|
|
|||
|
|
@ -139,6 +139,36 @@ public class LayoutConformanceTests
|
|||
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)
|
||||
{
|
||||
if (node.FontDid != 0) acc.Add(node.FontDid);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
|
|
@ -87,4 +88,42 @@ public class UiDatElementTests
|
|||
// No DefaultStateName, no "Normal" state → ActiveState stays "" (DirectState).
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
138
tests/AcDream.App.Tests/UI/Layout/UiPropertyBagTests.cs
Normal file
138
tests/AcDream.App.Tests/UI/Layout/UiPropertyBagTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -16,7 +16,10 @@ public class VitalsBindingTests
|
|||
public void Bind_SetsHealthMeterFillFromProvider()
|
||||
{
|
||||
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;
|
||||
|
||||
VitalsController.Bind(layout,
|
||||
|
|
@ -28,9 +31,9 @@ public class VitalsBindingTests
|
|||
manaText: () => "");
|
||||
|
||||
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.Equal("42/100", NumberText(health));
|
||||
Assert.Equal("42/100", NumberText(healthText));
|
||||
}
|
||||
|
||||
// ── Test 2: All three meters wired to distinct providers ──────────────────
|
||||
|
|
@ -41,10 +44,16 @@ public class VitalsBindingTests
|
|||
var health = new UiMeter();
|
||||
var stamina = new UiMeter();
|
||||
var mana = new UiMeter();
|
||||
var healthText = new UiText();
|
||||
var staminaText = new UiText();
|
||||
var manaText = new UiText();
|
||||
var layout = FakeLayout(
|
||||
(VitalsController.Health, health),
|
||||
(VitalsController.Stamina, stamina),
|
||||
(VitalsController.Mana, mana));
|
||||
(VitalsController.Mana, mana),
|
||||
(VitalsController.HealthText, healthText),
|
||||
(VitalsController.StaminaText, staminaText),
|
||||
(VitalsController.ManaText, manaText));
|
||||
|
||||
VitalsController.Bind(layout,
|
||||
healthPct: () => 0.25f,
|
||||
|
|
@ -56,13 +65,13 @@ public class VitalsBindingTests
|
|||
|
||||
// Each meter should reflect its own provider, not another's.
|
||||
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("50/100", NumberText(stamina));
|
||||
Assert.Equal("50/100", NumberText(staminaText));
|
||||
|
||||
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) ─────────────
|
||||
|
|
@ -89,12 +98,12 @@ public class VitalsBindingTests
|
|||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>The cur/max text from the centered <see cref="UiText"/> number that
|
||||
/// <see cref="VitalsController"/> attaches as the meter's child.</summary>
|
||||
private static string NumberText(UiMeter m)
|
||||
/// <summary>The cur/max text from the authored <see cref="UiText"/> node.</summary>
|
||||
private static string NumberText(UiText num)
|
||||
{
|
||||
var num = Assert.IsType<UiText>(m.Children[0]);
|
||||
Assert.True(num.Centered);
|
||||
Assert.True(num.OneLine);
|
||||
Assert.False(num.Selectable);
|
||||
var lines = num.LinesProvider();
|
||||
return lines.Count > 0 ? lines[0].Text : "";
|
||||
}
|
||||
|
|
|
|||
30486
tests/AcDream.App.Tests/UI/Layout/fixtures/character_2100002E.json
Normal file
30486
tests/AcDream.App.Tests/UI/Layout/fixtures/character_2100002E.json
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
17057
tests/AcDream.App.Tests/UI/Layout/fixtures/inventory_21000023.json
Normal file
17057
tests/AcDream.App.Tests/UI/Layout/fixtures/inventory_21000023.json
Normal file
File diff suppressed because it is too large
Load diff
11218
tests/AcDream.App.Tests/UI/Layout/fixtures/paperdoll_21000024.json
Normal file
11218
tests/AcDream.App.Tests/UI/Layout/fixtures/paperdoll_21000024.json
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
12227
tests/AcDream.App.Tests/UI/Layout/fixtures/toolbar_21000016.json
Normal file
12227
tests/AcDream.App.Tests/UI/Layout/fixtures/toolbar_21000016.json
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
39
tests/AcDream.App.Tests/UI/UiButtonStateMachineTests.cs
Normal file
39
tests/AcDream.App.Tests/UI/UiButtonStateMachineTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -22,4 +22,140 @@ public class UiButtonTests
|
|||
var b = new UiButton(new ElementInfo { Type = 1 }, NoTex);
|
||||
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 };
|
||||
}
|
||||
|
|
|
|||
132
tests/AcDream.App.Tests/UI/UiLayoutPolicyTests.cs
Normal file
132
tests/AcDream.App.Tests/UI/UiLayoutPolicyTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -482,4 +482,99 @@ public class UiRootInputTests
|
|||
Assert.True(clicked.ZOrder > peer.ZOrder);
|
||||
root.OnMouseUp(UiMouseButton.Left, 50, 50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddChild_AssignsEventIdsRecursively()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var parent = new UiPanel { Width = 100, Height = 100 };
|
||||
var child = new UiPanel { Width = 50, Height = 50 };
|
||||
var grandchild = new UiPanel { Width = 25, Height = 25 };
|
||||
child.AddChild(grandchild);
|
||||
parent.AddChild(child);
|
||||
|
||||
root.AddChild(parent);
|
||||
|
||||
Assert.NotEqual(0u, parent.EventId);
|
||||
Assert.NotEqual(0u, child.EventId);
|
||||
Assert.NotEqual(0u, grandchild.EventId);
|
||||
Assert.Equal(3, new[] { parent.EventId, child.EventId, grandchild.EventId }.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemovingSubtree_ClearsAllRootOwnership()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var window = new UiPanel { Width = 200, Height = 100 };
|
||||
var field = new UiField { Width = 100, Height = 20 };
|
||||
window.AddChild(field);
|
||||
root.AddChild(window);
|
||||
root.RegisterWindow("test", window);
|
||||
root.DefaultTextInput = field;
|
||||
root.Modal = window;
|
||||
root.SetKeyboardFocus(field);
|
||||
root.SetCapture(field);
|
||||
|
||||
Assert.True(root.RemoveChild(window));
|
||||
|
||||
Assert.Null(root.KeyboardFocus);
|
||||
Assert.Null(root.Captured);
|
||||
Assert.Null(root.DefaultTextInput);
|
||||
Assert.Null(root.Modal);
|
||||
Assert.False(root.ShowWindow("test"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_ChecksTooltipBeforeGlobalTimeMessage_AtRetailDelay()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var recorder = new TimeOrderRecorder { Width = 100, Height = 100 };
|
||||
root.AddChild(recorder);
|
||||
|
||||
root.Tick(0, 1_000);
|
||||
root.OnMouseMove(10, 10);
|
||||
recorder.Events.Clear();
|
||||
root.Tick(0, 1_249);
|
||||
Assert.DoesNotContain("tooltip", recorder.Events);
|
||||
recorder.Events.Clear();
|
||||
|
||||
root.Tick(0, 1_250);
|
||||
|
||||
Assert.Equal(new[] { "tooltip", "global" }, recorder.Events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_GlobalTimeRemoval_SkipsDetachedSiblingWithoutInvalidatingWalk()
|
||||
{
|
||||
var root = new UiRoot { Width = 800, Height = 600 };
|
||||
var victim = new TimeOrderRecorder { Width = 100, Height = 100 };
|
||||
var remover = new GlobalTimeAction(() => root.RemoveChild(victim))
|
||||
{ Width = 100, Height = 100 };
|
||||
root.AddChild(remover);
|
||||
root.AddChild(victim);
|
||||
|
||||
root.Tick(0, 1_000);
|
||||
|
||||
Assert.Empty(victim.Events);
|
||||
Assert.Null(victim.Parent);
|
||||
}
|
||||
|
||||
private sealed class TimeOrderRecorder : UiElement, IUiGlobalTimeListener
|
||||
{
|
||||
public List<string> Events { get; } = new();
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type != UiEventType.Tooltip) return false;
|
||||
Events.Add("tooltip");
|
||||
return true;
|
||||
}
|
||||
|
||||
public void OnGlobalUiTime(double nowSeconds) => Events.Add("global");
|
||||
}
|
||||
|
||||
private sealed class GlobalTimeAction(Action action) : UiElement, IUiGlobalTimeListener
|
||||
{
|
||||
public void OnGlobalUiTime(double nowSeconds) => action();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue