fix(ui): share retail tab state binding
Replace the character window's synthesized tab sprites, labels, colors, and hit panels with the same DAT-authored Open/Closed state path used by Spellbook/Components. Preserve controller-owned page selection while letting UIElement state propagation own tab chrome and typography. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
1249ad03de
commit
15bb61e05d
5 changed files with 179 additions and 285 deletions
|
|
@ -255,6 +255,26 @@ before its retained children lets the center chrome overpaint the caption.
|
||||||
This rule is derived structurally from `PassToChildren`, not from the two
|
This rule is derived structurally from `PassToChildren`, not from the two
|
||||||
spellbook element ids.
|
spellbook element ids.
|
||||||
|
|
||||||
|
The same mechanism applies to the Character window `0x2100002E`. Its
|
||||||
|
Attributes `0x10000228`, Skills `0x10000229`, and Titles `0x10000538` controls
|
||||||
|
are also compound Type-12 tabs with Closed/Open state color and three authored
|
||||||
|
chrome children. The controller reduction is therefore shared:
|
||||||
|
|
||||||
|
```text
|
||||||
|
BindRetailTab(tab, activate):
|
||||||
|
tab.Click = activate
|
||||||
|
|
||||||
|
SetActiveTab(active):
|
||||||
|
for each tab:
|
||||||
|
tab.SetState(tab == active ? Open(12) : Closed(11))
|
||||||
|
// UIElement::SetState propagates the same state to the retained children
|
||||||
|
```
|
||||||
|
|
||||||
|
Page visibility remains controller-owned because the three character page
|
||||||
|
containers carry no active-page visibility state. Tab appearance does not:
|
||||||
|
injecting replacement sprites or hard-coded caption colors bypasses the exact
|
||||||
|
same DAT state machine already used by Spellbook/Components.
|
||||||
|
|
||||||
Layout `0x21000034` supplies the 300 by 600 content surface, including its tab
|
Layout `0x21000034` supplies the 300 by 600 content surface, including its tab
|
||||||
bar and close control, but not the enclosing movable-window border. Production
|
bar and close control, but not the enclosing movable-window border. Production
|
||||||
mounts it through the same shared retained nine-slice frame used by the
|
mounts it through the same shared retained nine-slice frame used by the
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,10 @@ namespace AcDream.App.UI.Layout;
|
||||||
/// line-1 label = "Experience To Raise:", line-1 value = raise cost,
|
/// line-1 label = "Experience To Raise:", line-1 value = raise cost,
|
||||||
/// line-2 label = "Unassigned Experience:", line-2 value = UnassignedXp.</para>
|
/// line-2 label = "Unassigned Experience:", line-2 value = UnassignedXp.</para>
|
||||||
///
|
///
|
||||||
/// <para>Tab button states: Attributes = "Open", Skills = "Closed", Titles = "Closed".
|
/// <para>Tab states: Attributes = "Open", Skills = "Closed", Titles = "Closed".
|
||||||
/// Source: UIStateId.Open (0x0C) / UIStateId.Closed (0x0B) — set on the UiButton children
|
/// Source: <c>UIElement::SetState @ 0x00464E70</c> and UIStateId.Open (0x0C) /
|
||||||
/// inside each tab group container.</para>
|
/// UIStateId.Closed (0x0B). The imported Type-12 tab owns its authored font color and
|
||||||
|
/// propagates the state to its three chrome children through PassToChildren.</para>
|
||||||
///
|
///
|
||||||
/// <para>Raise buttons: 0x10000246 (×1) + 0x100005EB (×10). State "Normal" = affordable
|
/// <para>Raise buttons: 0x10000246 (×1) + 0x100005EB (×10). State "Normal" = affordable
|
||||||
/// (UIStateId.Normal, 0x01), state "Ghosted" = unaffordable or no selection
|
/// (UIStateId.Normal, 0x01), state "Ghosted" = unaffordable or no selection
|
||||||
|
|
@ -73,26 +74,10 @@ public static class CharacterStatController
|
||||||
public const uint FooterStateCId = 0x10000247u; // State-C footer container (hide inactive)
|
public const uint FooterStateCId = 0x10000247u; // State-C footer container (hide inactive)
|
||||||
|
|
||||||
// ── Tab bar element ids (LayoutDesc 0x2100002E root) ────────────────────
|
// ── Tab bar element ids (LayoutDesc 0x2100002E root) ────────────────────
|
||||||
// In the imported tree each tab group (0x10000228/229/538) resolves to a UiText with
|
// These are imported Type-12 UIElement_Text tabs. Their Closed/Open states carry
|
||||||
// ConsumesDatChildren=true, so the three button children (left-cap, label, right-cap)
|
// the caption color and PassToChildren=true; their three retained children carry
|
||||||
// are consumed at import time and not present in the widget tree.
|
// the authored left/center/right chrome. Character and Spellbook therefore share
|
||||||
//
|
// RetailTabBinding and the generic IUiDatStateful path rather than synthesizing art.
|
||||||
// WHY this is NOT fixed in Fix 5 (importer series):
|
|
||||||
// The Fix 5 meter patch only builds NON-Type-3 children of UiMeter. The tab group children
|
|
||||||
// are children of UiText (Type 12), not UiMeter (Type 7). Flipping UiText.ConsumesDatChildren
|
|
||||||
// globally is not safe — the chat transcript UiText and other Type-12 elements also have
|
|
||||||
// children in some layouts (scroll decorators etc.), and building them as live UiText widgets
|
|
||||||
// would risk invisible nodes stealing focus/clicks (the exact problem ConsumesDatChildren was
|
|
||||||
// designed to prevent). Additionally, the page-visibility pass in Bind() depends on
|
|
||||||
// tab group Children.Count==0 to skip them — adding children to the tab groups would break
|
|
||||||
// that pass and hide ALL tab content.
|
|
||||||
//
|
|
||||||
// Therefore: the controller injects 3 sprite-drawing root children per tab (absolute coords),
|
|
||||||
// using the known RenderSurface ids from the retail UI layout dump (2026-06-25):
|
|
||||||
// Closed (inactive) state: left=0x06005D93, center=0x06005D95, right=0x06005D97
|
|
||||||
// Open (active) state: left=0x06005D92, center=0x06005D94, right=0x06005D96
|
|
||||||
// Source: state_id 11=Closed, 12=Open per gmTabUI::SetActive(bool); sprite ids confirmed
|
|
||||||
// from retail UI layout dump nodes 0x10000439, 0x100000E9, 0x10000215 in layout 0x2100002E.
|
|
||||||
public const uint TabAttribId = 0x10000228u; // Attributes tab group
|
public const uint TabAttribId = 0x10000228u; // Attributes tab group
|
||||||
public const uint TabSkillsId = 0x10000229u; // Skills tab group
|
public const uint TabSkillsId = 0x10000229u; // Skills tab group
|
||||||
public const uint TabTitlesId = 0x10000538u; // Titles tab group
|
public const uint TabTitlesId = 0x10000538u; // Titles tab group
|
||||||
|
|
@ -104,21 +89,6 @@ public static class CharacterStatController
|
||||||
public const uint SkillsPageId = 0x1000022Cu;
|
public const uint SkillsPageId = 0x1000022Cu;
|
||||||
public const uint TitlesPageId = 0x10000539u;
|
public const uint TitlesPageId = 0x10000539u;
|
||||||
|
|
||||||
// Tab button piece widths and shared height (from dump node rects).
|
|
||||||
private const float TabLeftCapW = 17f; // left-cap button width
|
|
||||||
private const float TabCenterW = 58f; // center label button width
|
|
||||||
private const float TabRightCapW = 17f; // right-cap button width
|
|
||||||
private const float TabH = 25f; // button height
|
|
||||||
|
|
||||||
// Tab sprite ids per state (Open = active, Closed = inactive).
|
|
||||||
// Source: retail UI layout dump nodes 0x10000439/0x100000E9/0x10000215 in 0x2100002E.
|
|
||||||
private const uint TabOpenLeft = 0x06005D92u;
|
|
||||||
private const uint TabOpenCenter = 0x06005D94u;
|
|
||||||
private const uint TabOpenRight = 0x06005D96u;
|
|
||||||
private const uint TabClosedLeft = 0x06005D93u;
|
|
||||||
private const uint TabClosedCenter = 0x06005D95u;
|
|
||||||
private const uint TabClosedRight = 0x06005D97u;
|
|
||||||
|
|
||||||
// ── Footer element ids (gmStatManagementUI struct fields) ────────────────
|
// ── Footer element ids (gmStatManagementUI struct fields) ────────────────
|
||||||
// Source: acclient.h / DisplayDefaultFooter (0x0049cde0)
|
// Source: acclient.h / DisplayDefaultFooter (0x0049cde0)
|
||||||
public const uint FooterTitleId = 0x1000024eu; // GetFooterTitleLabel
|
public const uint FooterTitleId = 0x1000024eu; // GetFooterTitleLabel
|
||||||
|
|
@ -134,11 +104,6 @@ public static class CharacterStatController
|
||||||
public const uint RaiseOneId = 0x10000246u; // raise × 1
|
public const uint RaiseOneId = 0x10000246u; // raise × 1
|
||||||
public const uint RaiseTenId = 0x100005EBu; // raise × 10
|
public const uint RaiseTenId = 0x100005EBu; // raise × 10
|
||||||
|
|
||||||
// ── UIStateId string keys (DatReaderWriter UIStateId enum.ToString()) ────
|
|
||||||
// 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 static readonly Vector4 Body = new(0.92f, 0.90f, 0.82f, 1f); // parchment-white body text
|
private static readonly Vector4 Body = new(0.92f, 0.90f, 0.82f, 1f); // parchment-white body text
|
||||||
private static readonly Vector4 Gold = new(1f, 0.82f, 0.36f, 1f); // section / emphasis gold
|
private static readonly Vector4 Gold = new(1f, 0.82f, 0.36f, 1f); // section / emphasis gold
|
||||||
|
|
||||||
|
|
@ -203,16 +168,6 @@ public static class CharacterStatController
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public delegate void RaiseRequestHandler(RaiseRequest request, Action completed);
|
public delegate void RaiseRequestHandler(RaiseRequest request, Action completed);
|
||||||
|
|
||||||
private sealed class TabVisual
|
|
||||||
{
|
|
||||||
public required CharacterStatTab Tab { get; init; }
|
|
||||||
public required UiText Left { get; init; }
|
|
||||||
public required UiText Center { get; init; }
|
|
||||||
public required UiText Right { get; init; }
|
|
||||||
public required UiText Label { get; init; }
|
|
||||||
public required string Text { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed record SkillRowBinding(UiClickablePanel Panel, CharacterSkill Skill);
|
private sealed record SkillRowBinding(UiClickablePanel Panel, CharacterSkill Skill);
|
||||||
|
|
||||||
// ── Attribute row descriptors — retail display order per spec §1 ─────────
|
// ── Attribute row descriptors — retail display order per spec §1 ─────────
|
||||||
|
|
@ -270,7 +225,9 @@ public static class CharacterStatController
|
||||||
var activeListEntries = new List<UiElement>();
|
var activeListEntries = new List<UiElement>();
|
||||||
var currentAttributeRows = new List<UiClickablePanel>();
|
var currentAttributeRows = new List<UiClickablePanel>();
|
||||||
var currentSkillRows = new List<SkillRowBinding>();
|
var currentSkillRows = new List<SkillRowBinding>();
|
||||||
var tabVisuals = new List<TabVisual>();
|
UiElement? attributesTab = layout.FindElement(TabAttribId);
|
||||||
|
UiElement? skillsTab = layout.FindElement(TabSkillsId);
|
||||||
|
UiElement? titlesTab = layout.FindElement(TabTitlesId);
|
||||||
UiElement? contentPage = FindDirectChildById(layout.Root, AttributesPageId);
|
UiElement? contentPage = FindDirectChildById(layout.Root, AttributesPageId);
|
||||||
|
|
||||||
// Name (18px from dat FontDid), Heritage (14px), PkStatus (14px):
|
// Name (18px from dat FontDid), Heritage (14px), PkStatus (14px):
|
||||||
|
|
@ -350,20 +307,8 @@ public static class CharacterStatController
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tab bar sprite children (added to PANEL ROOT, not to the tab groups) ───
|
// The tab visuals are already retained in the imported LayoutDesc. Controllers
|
||||||
// The tab groups (0x10000228/229/538) are UiText (Type 12) with
|
// bind only click behavior and the active Open/Closed state below.
|
||||||
// ConsumesDatChildren=true, so their 3 button children were consumed at import.
|
|
||||||
// We inject the visual equivalent — 3 sprite UiTexts per tab — as CHILDREN OF
|
|
||||||
// layout.Root at the tab's ABSOLUTE screen position, NOT as children of the tab
|
|
||||||
// groups. This matters because the page-visibility pass (below) iterates
|
|
||||||
// root.Children and hides any child that doesn't contain NameId; if we added
|
|
||||||
// sprites to the tab groups, those groups would have non-zero Children.Count
|
|
||||||
// and be eligible for the pass, then hidden (since NameId is in the content area).
|
|
||||||
// By adding to root instead, the tab groups keep Children.Count==0 and are skipped
|
|
||||||
// by "if (page.Children.Count == 0) continue" — they stay visible always.
|
|
||||||
// Source: retail dump tab rects confirmed (2026-06-25):
|
|
||||||
// Attributes = (0,0,92,25), Skills = (92,0,92,25), Titles = (184,0,92,25).
|
|
||||||
// Interactive tab sprites are injected after the list/footer closures are wired.
|
|
||||||
|
|
||||||
// ── Attribute list — 9 rows in list box 0x1000023D ────────────────────
|
// ── Attribute list — 9 rows in list box 0x1000023D ────────────────────
|
||||||
// Mutable selected-index box: -1 = nothing selected.
|
// Mutable selected-index box: -1 = nothing selected.
|
||||||
|
|
@ -476,23 +421,12 @@ public static class CharacterStatController
|
||||||
() => currentSkillRows, onRaiseRequest, RefreshAfterRaise);
|
() => currentSkillRows, onRaiseRequest, RefreshAfterRaise);
|
||||||
RebuildActiveList();
|
RebuildActiveList();
|
||||||
|
|
||||||
if (layout.Root is { } tabRoot2 && spriteResolve is not null)
|
RetailTabBinding.SetClick(attributesTab, () => SwitchTab(CharacterStatTab.Attributes));
|
||||||
{
|
RetailTabBinding.SetClick(skillsTab, () => SwitchTab(CharacterStatTab.Skills));
|
||||||
if (layout.FindElement(TabAttribId) is { } origAttr) origAttr.Visible = false;
|
// Titles remain the known AP-109 gap. Keep its retail-authored closed visual,
|
||||||
if (layout.FindElement(TabSkillsId) is { } origSkills) origSkills.Visible = false;
|
// but do not make an inert page look interactive until that controller lands.
|
||||||
if (layout.FindElement(TabTitlesId) is { } origTitles) origTitles.Visible = false;
|
RetailTabBinding.SetClick(titlesTab, null);
|
||||||
|
UpdateTabStates();
|
||||||
tabVisuals.Add(AddTabSpritesToRoot(tabRoot2, spriteResolve, datFont,
|
|
||||||
tabX: 0f, label: "Attributes", tab: CharacterStatTab.Attributes,
|
|
||||||
onClick: () => SwitchTab(CharacterStatTab.Attributes)));
|
|
||||||
tabVisuals.Add(AddTabSpritesToRoot(tabRoot2, spriteResolve, datFont,
|
|
||||||
tabX: 92f, label: "Skills", tab: CharacterStatTab.Skills,
|
|
||||||
onClick: () => SwitchTab(CharacterStatTab.Skills)));
|
|
||||||
AddTabSpritesToRoot(tabRoot2, spriteResolve, datFont,
|
|
||||||
tabX: 184f, label: "Titles", tab: CharacterStatTab.Attributes,
|
|
||||||
onClick: static () => { });
|
|
||||||
UpdateTabVisuals(tabVisuals, activeTab[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Active-page selection (fixes the dark-overlay) ─────────────────────
|
// ── Active-page selection (fixes the dark-overlay) ─────────────────────
|
||||||
// WHY this cannot be done in the importer (dat state-model audit 2026-06-26):
|
// WHY this cannot be done in the importer (dat state-model audit 2026-06-26):
|
||||||
|
|
@ -520,10 +454,17 @@ public static class CharacterStatController
|
||||||
SetFooterSelected(false);
|
SetFooterSelected(false);
|
||||||
RebuildActiveList();
|
RebuildActiveList();
|
||||||
RefreshActiveRaiseButtons();
|
RefreshActiveRaiseButtons();
|
||||||
UpdateTabVisuals(tabVisuals, tab);
|
UpdateTabStates();
|
||||||
Console.WriteLine($"[CharacterStat] Tab click: {tab}");
|
Console.WriteLine($"[CharacterStat] Tab click: {tab}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void UpdateTabStates()
|
||||||
|
{
|
||||||
|
RetailTabBinding.SetOpen(attributesTab, activeTab[0] == CharacterStatTab.Attributes);
|
||||||
|
RetailTabBinding.SetOpen(skillsTab, activeTab[0] == CharacterStatTab.Skills);
|
||||||
|
RetailTabBinding.SetOpen(titlesTab, false);
|
||||||
|
}
|
||||||
|
|
||||||
void RebuildActiveList()
|
void RebuildActiveList()
|
||||||
{
|
{
|
||||||
if (statList is null) return;
|
if (statList is null) return;
|
||||||
|
|
@ -1849,124 +1790,6 @@ public static class CharacterStatController
|
||||||
t.LinesProvider = () => new[] { new UiText.Line(text(), color) };
|
t.LinesProvider = () => new[] { new UiText.Line(text(), color) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Add the three sprite pieces and the label for ONE tab to <paramref name="root"/>
|
|
||||||
/// as direct root children at absolute positions. Sprites go to root (not to the
|
|
||||||
/// tab group elements) so the tab groups keep Children.Count==0 and survive the
|
|
||||||
/// page-visibility pass unchanged.
|
|
||||||
///
|
|
||||||
/// <para>Sprite ids from the retail UI layout dump (2026-06-25), nodes
|
|
||||||
/// 0x10000439/0x100000E9/0x10000215 in layout 0x2100002E:
|
|
||||||
/// Open (active): 0x06005D92/0x06005D94/0x06005D96;
|
|
||||||
/// Closed (inactive): 0x06005D93/0x06005D95/0x06005D97.</para>
|
|
||||||
///
|
|
||||||
/// <para>Tab geometry confirmed from dump: each tab = 92px wide, 25px tall.
|
|
||||||
/// Left-cap = 17px, center = 58px, right-cap = 17px (total 92px).</para>
|
|
||||||
/// </summary>
|
|
||||||
private static TabVisual AddTabSpritesToRoot(
|
|
||||||
UiElement root,
|
|
||||||
Func<uint, (uint handle, int w, int h)> spriteResolve,
|
|
||||||
UiDatFont? datFont,
|
|
||||||
float tabX,
|
|
||||||
string label,
|
|
||||||
CharacterStatTab tab,
|
|
||||||
Action onClick)
|
|
||||||
{
|
|
||||||
uint leftId = TabClosedLeft;
|
|
||||||
uint centerId = TabClosedCenter;
|
|
||||||
uint rightId = TabClosedRight;
|
|
||||||
|
|
||||||
// Left cap: 17×25 at (tabX, 0)
|
|
||||||
var left = MakeSpriteElement(spriteResolve, x: tabX, y: 0f, w: TabLeftCapW, h: TabH, spriteId: leftId);
|
|
||||||
root.AddChild(left);
|
|
||||||
// Center: 58×25 at (tabX+17, 0) — carries the label text on top
|
|
||||||
var center = MakeSpriteElement(spriteResolve, x: tabX + TabLeftCapW, y: 0f, w: TabCenterW, h: TabH, spriteId: centerId);
|
|
||||||
root.AddChild(center);
|
|
||||||
// Right cap: 17×25 at (tabX+75, 0)
|
|
||||||
var right = MakeSpriteElement(spriteResolve, x: tabX + TabLeftCapW + TabCenterW, y: 0f, w: TabRightCapW, h: TabH, spriteId: rightId);
|
|
||||||
root.AddChild(right);
|
|
||||||
|
|
||||||
// Label text centered on the tab center piece.
|
|
||||||
// Active (Open) tab: gold; inactive (Closed) tabs: parchment body color.
|
|
||||||
// ZOrder=9 → draws on top of the sprite background (ZOrder=8) and original tab groups (ZOrder=1–3).
|
|
||||||
Vector4 labelColor = Body;
|
|
||||||
string capturedLabel = label;
|
|
||||||
var labelEl = new UiText
|
|
||||||
{
|
|
||||||
Left = tabX + TabLeftCapW,
|
|
||||||
Top = 0f,
|
|
||||||
Width = TabCenterW,
|
|
||||||
Height = TabH,
|
|
||||||
ZOrder = 9,
|
|
||||||
DatFont = datFont,
|
|
||||||
ClickThrough = true,
|
|
||||||
Centered = true,
|
|
||||||
OneLine = true,
|
|
||||||
Anchors = AnchorEdges.Left | AnchorEdges.Top,
|
|
||||||
};
|
|
||||||
labelEl.LinesProvider = () => new[] { new UiText.Line(capturedLabel, labelColor) };
|
|
||||||
root.AddChild(labelEl);
|
|
||||||
|
|
||||||
var hit = new UiClickablePanel
|
|
||||||
{
|
|
||||||
Left = tabX,
|
|
||||||
Top = 0f,
|
|
||||||
Width = TabLeftCapW + TabCenterW + TabRightCapW,
|
|
||||||
Height = TabH,
|
|
||||||
ZOrder = 10,
|
|
||||||
BackgroundColor = Vector4.Zero,
|
|
||||||
BorderColor = Vector4.Zero,
|
|
||||||
Anchors = AnchorEdges.Left | AnchorEdges.Top,
|
|
||||||
OnClick = onClick,
|
|
||||||
};
|
|
||||||
root.AddChild(hit);
|
|
||||||
|
|
||||||
return new TabVisual
|
|
||||||
{
|
|
||||||
Tab = tab,
|
|
||||||
Left = left,
|
|
||||||
Center = center,
|
|
||||||
Right = right,
|
|
||||||
Label = labelEl,
|
|
||||||
Text = label,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void UpdateTabVisuals(IReadOnlyList<TabVisual> tabs, CharacterStatTab activeTab)
|
|
||||||
{
|
|
||||||
foreach (var tab in tabs)
|
|
||||||
{
|
|
||||||
bool isOpen = tab.Tab == activeTab;
|
|
||||||
tab.Left.BackgroundSprite = isOpen ? TabOpenLeft : TabClosedLeft;
|
|
||||||
tab.Center.BackgroundSprite = isOpen ? TabOpenCenter : TabClosedCenter;
|
|
||||||
tab.Right.BackgroundSprite = isOpen ? TabOpenRight : TabClosedRight;
|
|
||||||
Vector4 color = isOpen ? Gold : Body;
|
|
||||||
string text = tab.Text;
|
|
||||||
tab.Label.LinesProvider = () => new[] { new UiText.Line(text, color) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Build a single sprite-drawing <see cref="UiText"/> leaf at the given position.
|
|
||||||
/// ZOrder=8 places it above the original tab-group UiTexts (ZOrder=1–3) so it draws on top.</summary>
|
|
||||||
private static UiText MakeSpriteElement(
|
|
||||||
Func<uint, (uint handle, int w, int h)> spriteResolve,
|
|
||||||
float x, float y, float w, float h, uint spriteId)
|
|
||||||
{
|
|
||||||
return new UiText
|
|
||||||
{
|
|
||||||
Left = x,
|
|
||||||
Top = y,
|
|
||||||
Width = w,
|
|
||||||
Height = h,
|
|
||||||
ZOrder = 8,
|
|
||||||
BackgroundSprite = spriteId,
|
|
||||||
SpriteResolve = id => { var (tex, tw, th) = spriteResolve(id); return (tex, tw, th); },
|
|
||||||
LinesProvider = static () => System.Array.Empty<UiText.Line>(),
|
|
||||||
ClickThrough = true,
|
|
||||||
Anchors = AnchorEdges.Left | AnchorEdges.Top,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Depth-first tree walk to collect every <see cref="UiButton"/> that was registered
|
/// Depth-first tree walk to collect every <see cref="UiButton"/> that was registered
|
||||||
/// in <paramref name="layout"/> under the given dat element <paramref name="id"/>.
|
/// in <paramref name="layout"/> under the given dat element <paramref name="id"/>.
|
||||||
|
|
|
||||||
39
src/AcDream.App/UI/Layout/RetailTabBinding.cs
Normal file
39
src/AcDream.App/UI/Layout/RetailTabBinding.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared controller seam for retail DAT-authored tabs. The LayoutDesc owns the
|
||||||
|
/// tab's font, colors, and child chrome; controllers only bind activation and
|
||||||
|
/// select the retail <c>Open</c>/<c>Closed</c> state.
|
||||||
|
/// </summary>
|
||||||
|
internal static class RetailTabBinding
|
||||||
|
{
|
||||||
|
public static void SetClick(UiElement? element, Action? action)
|
||||||
|
{
|
||||||
|
if (element is null) return;
|
||||||
|
|
||||||
|
element.ClickThrough = action is null;
|
||||||
|
switch (element)
|
||||||
|
{
|
||||||
|
case UiButton button: button.OnClick = action; break;
|
||||||
|
case UiText text: text.OnClick = action; break;
|
||||||
|
case UiDatElement dat: dat.OnClick = action; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool SetOpen(UiElement? element, bool open)
|
||||||
|
{
|
||||||
|
if (element is IUiDatStateful stateful
|
||||||
|
&& stateful.TrySetRetailState(open ? RetailUiStateIds.Open : RetailUiStateIds.Closed))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (element is UiButton button)
|
||||||
|
{
|
||||||
|
button.Selected = open;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -127,8 +127,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
||||||
_rowStyle = rowStyle;
|
_rowStyle = rowStyle;
|
||||||
_rowFont = rowFont;
|
_rowFont = rowFont;
|
||||||
|
|
||||||
SetClick(spellTab, () => ShowPage(SpellbookWindowPage.Spells));
|
RetailTabBinding.SetClick(spellTab, () => ShowPage(SpellbookWindowPage.Spells));
|
||||||
SetClick(componentTab, () => ShowPage(SpellbookWindowPage.Components));
|
RetailTabBinding.SetClick(componentTab, () => ShowPage(SpellbookWindowPage.Components));
|
||||||
closeButton.OnClick = close;
|
closeButton.OnClick = close;
|
||||||
deleteButton.OnClick = RequestDeleteSelected;
|
deleteButton.OnClick = RequestDeleteSelected;
|
||||||
foreach ((uint id, uint mask) in FilterButtons)
|
foreach ((uint id, uint mask) in FilterButtons)
|
||||||
|
|
@ -213,8 +213,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
||||||
CurrentPage = page;
|
CurrentPage = page;
|
||||||
_spellPage.Visible = page == SpellbookWindowPage.Spells;
|
_spellPage.Visible = page == SpellbookWindowPage.Spells;
|
||||||
_componentPage.Visible = page == SpellbookWindowPage.Components;
|
_componentPage.Visible = page == SpellbookWindowPage.Components;
|
||||||
SetTabOpen(_spellTab, page == SpellbookWindowPage.Spells);
|
RetailTabBinding.SetOpen(_spellTab, page == SpellbookWindowPage.Spells);
|
||||||
SetTabOpen(_componentTab, page == SpellbookWindowPage.Components);
|
RetailTabBinding.SetOpen(_componentTab, page == SpellbookWindowPage.Components);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ConfigureSpellList(ImportedLayout layout)
|
private void ConfigureSpellList(ImportedLayout layout)
|
||||||
|
|
@ -520,30 +520,11 @@ public sealed class SpellbookWindowController : IRetainedPanelController
|
||||||
_objects.ObjectRemoved -= OnObjectRemoved;
|
_objects.ObjectRemoved -= OnObjectRemoved;
|
||||||
_objects.ObjectMoved -= OnObjectMoved;
|
_objects.ObjectMoved -= OnObjectMoved;
|
||||||
_objects.ContainerContentsReplaced -= OnContainerContentsReplaced;
|
_objects.ContainerContentsReplaced -= OnContainerContentsReplaced;
|
||||||
SetClick(_spellTab, null);
|
RetailTabBinding.SetClick(_spellTab, null);
|
||||||
SetClick(_componentTab, null);
|
RetailTabBinding.SetClick(_componentTab, null);
|
||||||
_closeButton.OnClick = null;
|
_closeButton.OnClick = null;
|
||||||
_deleteButton.OnClick = null;
|
_deleteButton.OnClick = null;
|
||||||
foreach ((UiButton button, _) in _filters) button.OnClick = null;
|
foreach ((UiButton button, _) in _filters) button.OnClick = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void SetClick(UiElement element, Action? action)
|
|
||||||
{
|
|
||||||
element.ClickThrough = action is null;
|
|
||||||
switch (element)
|
|
||||||
{
|
|
||||||
case UiButton button: button.OnClick = action; break;
|
|
||||||
case UiText text: text.OnClick = action; break;
|
|
||||||
case UiDatElement dat: dat.OnClick = action; break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void SetTabOpen(UiElement element, bool open)
|
|
||||||
{
|
|
||||||
if (element is IUiDatStateful stateful
|
|
||||||
&& stateful.TrySetRetailState(open ? RetailUiStateIds.Open : RetailUiStateIds.Closed))
|
|
||||||
return;
|
|
||||||
if (element is UiButton button)
|
|
||||||
button.Selected = open;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -775,73 +775,56 @@ public class CharacterStatControllerTests
|
||||||
Assert.False(btn1.Visible, "raise button hidden after deselect");
|
Assert.False(btn1.Visible, "raise button hidden after deselect");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Pass 2: Tab button states ─────────────────────────────────────────────
|
// ── Pass 2: DAT-authored tab states ───────────────────────────────────────
|
||||||
// Tab sprites are added to layout.Root (NOT to the tab group elements), so
|
|
||||||
// the tab groups keep Children.Count==0 and survive the page-visibility pass.
|
|
||||||
// AddTabSpritesToRoot() adds 3 sprite UiTexts + 1 label UiText per tab to the
|
|
||||||
// root when a spriteResolve is provided; nothing is added when spriteResolve=null.
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TabButtons_NoSpriteResolve_AddsNoSpriteChildrenToRoot()
|
public void CharacterTabs_UseImportedChromeWithoutSyntheticRootChildren()
|
||||||
{
|
{
|
||||||
// When spriteResolve is null, AddTabSpritesToRoot is not called — no sprites added.
|
var layout = FixtureLoader.LoadCharacter();
|
||||||
var layout = Fake(); // empty fake layout with just the root UiPanel
|
int rootChildCount = layout.Root.Children.Count;
|
||||||
int rootChildCountBefore = layout.Root.Children.Count;
|
var attributes = Assert.IsType<UiText>(layout.FindElement(CharacterStatController.TabAttribId));
|
||||||
|
var skills = Assert.IsType<UiText>(layout.FindElement(CharacterStatController.TabSkillsId));
|
||||||
|
var titles = Assert.IsType<UiText>(layout.FindElement(CharacterStatController.TabTitlesId));
|
||||||
|
|
||||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: null);
|
Assert.Equal(3, attributes.Children.Count);
|
||||||
|
Assert.Equal(3, skills.Children.Count);
|
||||||
// No extra children added to root when spriteResolve is null.
|
Assert.Equal(3, titles.Children.Count);
|
||||||
Assert.Equal(rootChildCountBefore, layout.Root.Children.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void TabButtons_AttributesGroup_AddsOpenSpriteIdsToRoot()
|
|
||||||
{
|
|
||||||
// Attributes tab (isOpen=true): 3 sprite children with Open sprite ids on ROOT.
|
|
||||||
// The tab group element itself has 0 children (sprites go to root, not the group).
|
|
||||||
var layout = Fake(); // minimal fake
|
|
||||||
|
|
||||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||||||
spriteResolve: id => (id, 16, 16));
|
spriteResolve: id => (id, 16, 16));
|
||||||
|
|
||||||
// Root should contain the Open sprite ids among all tab sprite children.
|
Assert.Equal(rootChildCount, layout.Root.Children.Count);
|
||||||
var sprites = layout.Root.Children.OfType<UiText>()
|
Assert.Equal(RetailUiStateIds.Open, attributes.ActiveRetailStateId);
|
||||||
.Where(t => t.BackgroundSprite != 0u).ToList();
|
Assert.Equal(RetailUiStateIds.Closed, skills.ActiveRetailStateId);
|
||||||
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D92u); // Open left-cap
|
Assert.Equal(RetailUiStateIds.Closed, titles.ActiveRetailStateId);
|
||||||
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D94u); // Open center
|
Assert.Equal(
|
||||||
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D96u); // Open right-cap
|
new uint[] { 0x06005D92u, 0x06005D94u, 0x06005D96u },
|
||||||
|
attributes.Children.Cast<UiDatElement>().Select(child => child.ActiveMedia().File));
|
||||||
|
Assert.Equal(
|
||||||
|
new uint[] { 0x06005D93u, 0x06005D95u, 0x06005D97u },
|
||||||
|
skills.Children.Cast<UiDatElement>().Select(child => child.ActiveMedia().File));
|
||||||
|
Assert.True(titles.ClickThrough);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TabButtons_SkillsAndTitles_AddClosedSpriteIdsToRoot()
|
public void CharacterTabs_ClickUsesRetailStateColorAndPropagatesToChrome()
|
||||||
{
|
{
|
||||||
// Skills + Titles tabs (isOpen=false): Closed sprite ids appear on root.
|
var layout = FixtureLoader.LoadCharacter();
|
||||||
var layout = Fake();
|
var attributes = Assert.IsType<UiText>(layout.FindElement(CharacterStatController.TabAttribId));
|
||||||
|
var skills = Assert.IsType<UiText>(layout.FindElement(CharacterStatController.TabSkillsId));
|
||||||
|
|
||||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||||||
spriteResolve: id => (id, 16, 16));
|
spriteResolve: id => (id, 16, 16));
|
||||||
|
skills.OnClick!();
|
||||||
|
|
||||||
var sprites = layout.Root.Children.OfType<UiText>()
|
Assert.Equal(RetailUiStateIds.Closed, attributes.ActiveRetailStateId);
|
||||||
.Where(t => t.BackgroundSprite != 0u).ToList();
|
Assert.Equal(RetailUiStateIds.Open, skills.ActiveRetailStateId);
|
||||||
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D93u); // Closed left-cap
|
Assert.Equal(127f / 255f, attributes.DefaultColor.X, 5);
|
||||||
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D95u); // Closed center
|
Assert.Equal(204f / 255f, skills.DefaultColor.X, 5);
|
||||||
Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D97u); // Closed right-cap
|
Assert.All(attributes.Children, child =>
|
||||||
}
|
Assert.Equal(RetailUiStateIds.Closed, Assert.IsAssignableFrom<IUiDatStateful>(child).ActiveRetailStateId));
|
||||||
|
Assert.All(skills.Children, child =>
|
||||||
[Fact]
|
Assert.Equal(RetailUiStateIds.Open, Assert.IsAssignableFrom<IUiDatStateful>(child).ActiveRetailStateId));
|
||||||
public void TabButtons_WithSpriteResolve_AddsAllThreeTabsToRoot()
|
|
||||||
{
|
|
||||||
// With spriteResolve: all 3 tabs inject 4 children each (3 sprites + 1 label)
|
|
||||||
// = 12 sprite children total across 3 tabs on the root.
|
|
||||||
var layout = Fake();
|
|
||||||
|
|
||||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
|
||||||
spriteResolve: id => (id, 16, 16));
|
|
||||||
|
|
||||||
// 3 tabs × 3 sprites = 9 sprite UiTexts on root.
|
|
||||||
var sprites = layout.Root.Children.OfType<UiText>()
|
|
||||||
.Where(t => t.BackgroundSprite != 0u).ToList();
|
|
||||||
Assert.Equal(9, sprites.Count);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Affordability helpers (GetRaiseCost) ──────────────────────────────────
|
// ── Affordability helpers (GetRaiseCost) ──────────────────────────────────
|
||||||
|
|
@ -934,12 +917,15 @@ public class CharacterStatControllerTests
|
||||||
hiddenPage.AddChild(hiddenDuplicateList);
|
hiddenPage.AddChild(hiddenDuplicateList);
|
||||||
root.AddChild(attrPage);
|
root.AddChild(attrPage);
|
||||||
root.AddChild(hiddenPage);
|
root.AddChild(hiddenPage);
|
||||||
|
var skillsTab = MakeTab(CharacterStatController.TabSkillsId, left: 92f);
|
||||||
|
root.AddChild(skillsTab);
|
||||||
|
|
||||||
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
|
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
|
||||||
{
|
{
|
||||||
[CharacterStatController.NameId] = name,
|
[CharacterStatController.NameId] = name,
|
||||||
// Mirrors the real import: the id dictionary can point at a hidden duplicate.
|
// Mirrors the real import: the id dictionary can point at a hidden duplicate.
|
||||||
[CharacterStatController.ListBoxId] = hiddenDuplicateList,
|
[CharacterStatController.ListBoxId] = hiddenDuplicateList,
|
||||||
|
[CharacterStatController.TabSkillsId] = skillsTab,
|
||||||
});
|
});
|
||||||
|
|
||||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||||||
|
|
@ -950,7 +936,7 @@ public class CharacterStatControllerTests
|
||||||
|
|
||||||
var ui = new UiRoot { Width = 300, Height = 600 };
|
var ui = new UiRoot { Width = 300, Height = 600 };
|
||||||
ui.AddChild(root);
|
ui.AddChild(root);
|
||||||
Assert.IsType<UiClickablePanel>(ui.Pick(132, 12));
|
Assert.Same(skillsTab, ui.Pick(132, 12));
|
||||||
|
|
||||||
ui.OnMouseDown(UiMouseButton.Left, 132, 12);
|
ui.OnMouseDown(UiMouseButton.Left, 132, 12);
|
||||||
ui.OnMouseUp(UiMouseButton.Left, 132, 12);
|
ui.OnMouseUp(UiMouseButton.Left, 132, 12);
|
||||||
|
|
@ -1215,12 +1201,15 @@ public class CharacterStatControllerTests
|
||||||
page.AddChild(list);
|
page.AddChild(list);
|
||||||
page.AddChild(scrollbarShell);
|
page.AddChild(scrollbarShell);
|
||||||
root.AddChild(page);
|
root.AddChild(page);
|
||||||
|
var skillsTab = MakeTab(CharacterStatController.TabSkillsId, left: 92f);
|
||||||
|
root.AddChild(skillsTab);
|
||||||
|
|
||||||
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
|
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
|
||||||
{
|
{
|
||||||
[CharacterStatController.NameId] = name,
|
[CharacterStatController.NameId] = name,
|
||||||
[CharacterStatController.ListBoxId] = list,
|
[CharacterStatController.ListBoxId] = list,
|
||||||
[CharacterStatController.ListScrollbarId] = scrollbarShell,
|
[CharacterStatController.ListScrollbarId] = scrollbarShell,
|
||||||
|
[CharacterStatController.TabSkillsId] = skillsTab,
|
||||||
});
|
});
|
||||||
|
|
||||||
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
CharacterStatController.Bind(layout, SampleData.SampleCharacter,
|
||||||
|
|
@ -1589,10 +1578,13 @@ public class CharacterStatControllerTests
|
||||||
|
|
||||||
private static void ClickTab(ImportedLayout layout, float left)
|
private static void ClickTab(ImportedLayout layout, float left)
|
||||||
{
|
{
|
||||||
var tab = layout.Root.Children.OfType<UiClickablePanel>()
|
uint id = left switch
|
||||||
.Single(p => System.Math.Abs(p.Left - left) < 0.5f
|
{
|
||||||
&& System.Math.Abs(p.Top) < 0.5f
|
0f => CharacterStatController.TabAttribId,
|
||||||
&& System.Math.Abs(p.Height - 25f) < 0.5f);
|
92f => CharacterStatController.TabSkillsId,
|
||||||
|
_ => CharacterStatController.TabTitlesId,
|
||||||
|
};
|
||||||
|
var tab = Assert.IsType<UiText>(layout.FindElement(id));
|
||||||
tab.OnClick!();
|
tab.OnClick!();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1660,6 +1652,33 @@ public class CharacterStatControllerTests
|
||||||
return new UiButton(info, static _ => (0u, 0, 0));
|
return new UiButton(info, static _ => (0u, 0, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static UiText MakeTab(uint id, float left)
|
||||||
|
{
|
||||||
|
var info = new ElementInfo
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Type = 12,
|
||||||
|
X = left,
|
||||||
|
Width = 92f,
|
||||||
|
Height = 25f,
|
||||||
|
DefaultStateId = RetailUiStateIds.Closed,
|
||||||
|
DefaultStateName = "Closed",
|
||||||
|
};
|
||||||
|
info.States[RetailUiStateIds.Closed] = new UiStateInfo
|
||||||
|
{
|
||||||
|
Id = RetailUiStateIds.Closed,
|
||||||
|
Name = "Closed",
|
||||||
|
PassToChildren = true,
|
||||||
|
};
|
||||||
|
info.States[RetailUiStateIds.Open] = new UiStateInfo
|
||||||
|
{
|
||||||
|
Id = RetailUiStateIds.Open,
|
||||||
|
Name = "Open",
|
||||||
|
PassToChildren = true,
|
||||||
|
};
|
||||||
|
return Assert.IsType<UiText>(DatWidgetFactory.Create(info, static _ => (0u, 0, 0), null));
|
||||||
|
}
|
||||||
|
|
||||||
private static ImportedLayout Fake(params (uint id, UiElement e)[] items)
|
private static ImportedLayout Fake(params (uint id, UiElement e)[] items)
|
||||||
{
|
{
|
||||||
var dict = new Dictionary<uint, UiElement>();
|
var dict = new Dictionary<uint, UiElement>();
|
||||||
|
|
@ -1669,6 +1688,18 @@ public class CharacterStatControllerTests
|
||||||
root.AddChild(e);
|
root.AddChild(e);
|
||||||
dict[id] = e;
|
dict[id] = e;
|
||||||
}
|
}
|
||||||
|
foreach ((uint id, float left) in new[]
|
||||||
|
{
|
||||||
|
(CharacterStatController.TabAttribId, 0f),
|
||||||
|
(CharacterStatController.TabSkillsId, 92f),
|
||||||
|
(CharacterStatController.TabTitlesId, 184f),
|
||||||
|
})
|
||||||
|
{
|
||||||
|
if (dict.ContainsKey(id)) continue;
|
||||||
|
UiText tab = MakeTab(id, left);
|
||||||
|
root.AddChild(tab);
|
||||||
|
dict[id] = tab;
|
||||||
|
}
|
||||||
return new ImportedLayout(root, dict);
|
return new ImportedLayout(root, dict);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue