using System.Collections.Generic; using AcDream.App.UI.Layout; namespace AcDream.App.UI; /// /// Retail UIElement_TabControl (Type 8) — the Options panel's tab strip + /// mounted-page switcher. Owns the authored tab table (dat property 0x2E, /// see ): activating a tab shows exactly ONE page-slot /// child and hides the rest, and updates the corresponding tab button's Open/Closed /// visual state. /// /// /// 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). This widget only wires cross-references the tab /// table names by id, which it can only resolve once that subtree exists — see /// . /// /// /// /// 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 UiTabControl : UiElement, IUiChildrenAttachedListener { private readonly IReadOnlyList _tabs; public UiTabControl(IReadOnlyList tabs) { _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 mount. public uint ActivePageElementId { get; private set; } /// /// Retail wires the tab table's cross-references (button ↔ page ↔ default) once /// the imported subtree exists — see 's /// doc for why this can't happen during DatWidgetFactory.Create. Every tab /// button's click is bound to switch to its paired page, then the authored /// default tab (the entry with true, or /// the first entry if the dat authored none) is activated. /// public void OnChildrenAttached() { UiTabTableEntry? defaultEntry = null; foreach (UiTabTableEntry entry in _tabs) { UiElement? button = FindDirectChild(entry.ButtonElementId); uint pageId = entry.PageElementId; RetailTabBinding.SetClick(button, () => SwitchTo(pageId)); if (entry.IsDefault) defaultEntry = entry; } defaultEntry ??= _tabs.Count > 0 ? _tabs[0] : null; 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. /// public void SwitchTo(uint pageElementId) { if (ActivePageElementId == pageElementId) return; foreach (UiTabTableEntry entry in _tabs) { bool active = entry.PageElementId == pageElementId; UiElement? page = FindDirectChild(entry.PageElementId); if (page is not null) page.Visible = active; UiElement? button = FindDirectChild(entry.ButtonElementId); RetailTabBinding.SetOpen(button, active); } ActivePageElementId = pageElementId; } private UiElement? FindDirectChild(uint datElementId) { foreach (UiElement child in Children) if (child.DatElementId == datElementId) return child; return null; } }