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);
}
}