using System; using System.Collections.Generic; using AcDream.App.UI.Layout; namespace AcDream.App.UI; /// /// Retail UIElement_Panel (dat class Type 8) — the tab-strip host used by /// the Options panel and, in principle, any panel that authors a tab table (dat property /// 0x2E, see ). /// /// /// Naming correction (OP2 rework): the OP2 slice named this class/mechanism /// UIElement_TabControl. No such class exists in the named-retail PDB — Type 8 is /// registered as UIElement_Panel::Create (UIElement::RegisterElementClass(8, /// UIElement_Panel::Create) @0x0046C6B7). Correct anchors: /// UIElement_Panel::SetupTabPageHash @0x0046C2E0 (tab-table read + default /// selection), ::Update @0x0046BD00 (the one-visible-page switch + tab /// Open/Closed state write), ::OpenTab @0x0046BE20 / ::InqTabFromPage /// @0x0046BEB0 (the click entry points), ::ListenToElementMessage @0x0046BF90. /// /// /// /// Shape correction (OP2 rework, `docs/research/2026-08-11-op2-review-blast.md` + /// `docs/research/2026-08-11-op2-review-mechanism.md`): OP2 mapped every dat Type-8 /// element unconditionally to this class and wired its tab table AT IMPORT TIME /// (OnChildrenAttached). Four already-shipped panels (vendor `0x100000B8`, /// character sheet root `0x10000227`, spellbook root `0x100002A8`, combat /// `0x100000A2`) are Type 8 and authored a tab table, so they silently gained a SECOND, /// import-time tab-switcher racing their own existing C# controllers /// (CharacterStatController/SpellbookWindowController/ /// VendorUiController already own this exact switching for their panels; combat /// has no re-binder at all — the table would have taken over outright). A fifth Type-8 /// element, vendor's media-bearing backdrop `0x1000008D`, has NO tab table at all and /// simply lost its authored fill because the old bare- base drew /// nothing and defaulted to false. /// /// /// /// This class now derives from and is DORMANT by default: /// importing a Type-8 element only gets authored-media drawing, retail's /// ClickThrough = true generic-decoration default, and /// state propagation — identical to the pre-OP2 fallback for every element that doesn't /// opt in. The tab-table switching mechanism (button ↔ page ↔ default, Open/Closed state) /// only activates when a controller explicitly calls — /// today, nobody does (OP2 ships the mechanism only; the Options panel controller, /// Campaign OP slice OP3+, is the first caller). This is filed as an intentional /// deviation from retail's unconditional per-instance activation — see the register row /// added in this same commit. /// /// /// /// The tab buttons and page-slot children are ORDINARY imported dat elements — this /// widget does not set , so /// LayoutImporter builds them the normal recursive way (buttons build as /// with Open/Closed states — retail's tab buttons are Type 0xC /// text elements, not Type-1 buttons; page slots build as generic containers whose /// OWN children are the mounted page's content via BaseLayoutId/ /// BaseElement inheritance). /// /// /// /// Retail anchors: docs/research/2026-08-10-options-panel-structure.md §1.3 /// (the tab table property 0x2E struct shape), §10.1 (structural inventory — /// tab buttons `0x1000020D..0x1000020F`/`0x1000050B`, page slots /// `0x10000211..0x10000213`/`0x1000050C`, Gameplay the authored default). Verified /// byte-for-byte against the regenerated options_2100002B.json fixture: four /// entries, Gameplay (`0x1000020D`/`0x10000212`) is the sole IsDefault row. /// /// /// /// Retail's OnVisibilityChanged auto-apply/auto-revert semantics (research doc /// §3.6 — hiding a page reverts uncommitted edits, showing one applies + commits) are /// OUT of this widget's scope: they belong to the OptionPage/ /// PlayerOptionPage model a page controller owns (Campaign OP slice OP3+). /// This widget only switches which page slot is . /// /// public sealed class UiTabPanel : UiDatElement, IUiChildrenAttachedListener { /// Retail element id this class was ported for: Type 8 = 8. public const uint RetailTypeId = 8u; private readonly IReadOnlyList _tabs; private readonly List _unresolved = new(); private bool _behaviorActive; public UiTabPanel( ElementInfo info, Func resolve, IReadOnlyList tabs) : base(info, resolve) { _tabs = tabs; } /// The authored tab table this control was built from, in authored array order. public IReadOnlyList Tabs => _tabs; /// Dat element id of the currently active page slot. 0 before the first /// switch (either 's default-entry switch, or a /// direct call). public uint ActivePageElementId { get; private set; } /// /// Fires at the end of every call that actually /// changes the active page — including the FIRST switch, from /// 's default-entry activation (old = 0). /// A page-model owner (Campaign OP slice OP3's OptionsPanelController) /// subscribes here to drive retail's per-page /// OnVisibilityChanged(false)/OnVisibilityChanged(true) pair /// (research doc §3.6) on the leaving/entering page — this widget only /// owns which page slot is , not the /// page-model semantics layered on top of that switch. /// public event Action? ActivePageChanged; /// /// True once has run. Dormant instances (every /// pre-existing Type-8 host today) never flip this. /// public bool BehaviorActive => _behaviorActive; /// /// Tab-table entries whose button, page, or both did not resolve against this /// element's built subtree the last time ran — a /// silently-empty tab table and one that entirely fails to resolve are otherwise /// indistinguishable (round-2 review SHOULD-FIX 3), so this is loud instead of a /// mere no-op. Each miss is also logged via . /// public IReadOnlyList UnresolvedEntries => _unresolved; /// /// Dormant by design (see class doc) — importing a Type-8 element performs no /// wiring. is the explicit, controller-driven /// opt-in that does the equivalent work once the subtree exists AND a controller /// actually wants this element to own tab switching. /// void IUiChildrenAttachedListener.OnChildrenAttached() { } /// /// Opts this instance into retail's tab-table switching mechanism: binds every tab /// button's click to switch to its paired page (UIElement_Panel::OpenTab), /// then activates the authored default entry — the entry with /// true. Idempotent; a second call is a /// no-op. Retail resolves both the button and the page via /// GetChildRecursive (a descendant search, not direct-children-only), which /// this ports so cross-layout mounts (a tab page's content built from a different /// LayoutDesc, e.g. Configure Keyboard) still resolve. /// public void ActivateTabBehavior() { if (_behaviorActive) return; _behaviorActive = true; _unresolved.Clear(); UiTabTableEntry? defaultEntry = null; foreach (UiTabTableEntry entry in _tabs) { UiElement? button = FindDescendant(this, entry.ButtonElementId); UiElement? page = FindDescendant(this, entry.PageElementId); if (button is null || page is null) { _unresolved.Add(entry); Console.WriteLine( $"[D.2b] UiTabPanel 0x{Info.Id:X8}: tab entry button=0x{entry.ButtonElementId:X8} " + $"page=0x{entry.PageElementId:X8} did not resolve against the built subtree " + $"(button {(button is null ? "MISSING" : "ok")}, page {(page is null ? "MISSING" : "ok")})."); } uint pageId = entry.PageElementId; RetailTabBinding.SetClick(button, () => SwitchTo(pageId)); if (entry.IsDefault) defaultEntry = entry; } // Retail UIElement_Panel::Update(0, 0) — the guard when neither // m_OpenPageToken nor m_OpenTabToken has been seeded — performs no switch at // all. No entry authoring 0x32==true means retail never activates a page here; // do not fabricate a fallback to the first entry (the OP2 REJECT-review // SHOULD-FIX 2 finding). if (defaultEntry is { } def) SwitchTo(def.PageElementId); } /// /// Activates the page slot named : shows it, hides /// every other authored page slot, and sets each tab button's Open/Closed state to /// match (). No-op if the page is already /// active. Safe to call directly (e.g. from a test or a controller that wants /// programmatic navigation) without going through /// first, though the authored click bindings only exist after activation. /// public void SwitchTo(uint pageElementId) { if (ActivePageElementId == pageElementId) return; uint previousPageElementId = ActivePageElementId; foreach (UiTabTableEntry entry in _tabs) { bool active = entry.PageElementId == pageElementId; UiElement? page = FindDescendant(this, entry.PageElementId); if (page is not null) page.Visible = active; UiElement? button = FindDescendant(this, entry.ButtonElementId); RetailTabBinding.SetOpen(button, active); } ActivePageElementId = pageElementId; ActivePageChanged?.Invoke(previousPageElementId, pageElementId); } }