using System; using System.Collections.Generic; using AcDream.App.UI.Layout; namespace AcDream.App.UI; /// /// Retail UIElement_ListBox (dat class Type 5) with an authored row-template /// list (dat property 0x64). Port of AddItemFromTemplateList(index): /// instantiates row 's template — a /// naming a cross-layout {LayoutDesc DID, element id} pair, per /// docs/research/2026-08-10-options-panel-structure.md §1.5 — through the SAME /// import machinery every other retained window uses, and appends it as one row. /// /// /// OP2 rework (2026-08-11): derives from and stays /// DORMANT until a controller adds its first row. The OP2 REJECT-review blast-radius /// finding (`docs/research/2026-08-11-op2-review-blast.md`) established that EVERY /// pre-existing Type-5 element reaching already authors a /// non-empty 0x64 template array (character `0x1000023D`/`0x10000532`, effects /// positive/negative `0x10000123`, examine `0x10000149`/`0x10000335`/`0x1000032D`, /// mini-game `0x10000174`, spellbook `0x10000464`) — so the original Type-5 factory /// guard's premise ("none currently reach this factory") was false, and the old /// unconditional viewport injection in the constructor (base.AddChild(_viewport)) /// gave every one of those ten elements a spurious hit-testable /// child plus lost their authored media (the old class /// derived from bare , zeroed its background/border, and drew /// nothing dat-authored). None of the ten pre-existing elements author real dat /// children of their own (verified against every regenerated fixture — their row /// content comes ONLY from the cross-layout 0x64 array, never from a literal /// child in their own LayoutDesc subtree), so making this class dormant-by-default is a /// pure behavior restoration: an un-activated instance renders and hit-tests EXACTLY /// like the pre-OP2 fallback. The Options panel's three /// ListBoxes (Character/Config/Chat, Campaign OP slice OP4+) are the only elements a /// controller will ever call against. /// /// /// /// is true: retail ListBox rows come ONLY from /// AddItemFromTemplateList, never from static dat children of the ListBox element /// itself, so this widget owns its own child construction the same way /// Meter/Menu/Button/Scrollbar/Text/Field do — LayoutImporter does not attempt to /// recurse into any literal dat children this element might (incorrectly) author. /// /// /// /// Row content is stacked inside a lazily-created — /// created on the FIRST successful call, not in the /// constructor. This is what makes dormancy free: an instance that never gets a row /// (every pre-existing panel today) never allocates or attaches the viewport, so there is /// no spurious hit-testable child competing with whatever the controller separately adds /// via (the same "resolve → not a UiItemList → build one /// and attach it directly to the host" pattern EffectsUiController/ /// AppraisalUiController/SpellbookWindowController/ /// CharacterStatController already use for these exact ten elements). /// is sealed, hence composition rather than inheritance — /// the SAME "controller-built row list" viewport pattern /// CharacterStatController.RebuildActiveList already uses for the skill list. /// The wrapped panel is anchored to fill this box, so scrolling, per-row visibility /// clipping, and the pixel scroll model () all come from the SAME /// code CH6/the character sheet already exercise — no new scroll model. Rows stack in /// call order: each new row's is set to the viewport's /// current before it is added, exactly /// retail's own ListBox layout (each row is authored at its own template-local Y=0; the /// box stacks instances). /// /// /// /// is the seam a page controller wires with real DAT /// access — e.g. (layoutId, elementId) => LayoutImporter.Build( /// LayoutImporter.ImportInfos(dats, layoutId, elementId), resolve, datFont, /// fontResolve, stringResolve)?.Root (the SAME "one selected root from a /// catalog-style LayoutDesc" overload RetailDialogFactory already uses for the /// shared dialog catalog — the Options panel's row templates are top-level siblings /// of the tab control in 0x2100002B, structurally identical to that catalog /// shape). Left null by DatWidgetFactory itself: OP2 ships the mechanism, a /// page controller (Campaign OP slice OP4+) supplies the resolver once it has a live /// IDatReaderWriter. /// /// public sealed class UiTemplateListBox : UiDatElement { /// Retail element id this class was ported for: Type 5 = 5. public const uint RetailTypeId = 5u; private const int DefaultLineHeight = 16; private UiScrollablePanel? _viewport; private int _pendingLineHeight = DefaultLineHeight; /// The authored row-template list (dat property 0x64), in authored array order. public IReadOnlyList Templates { get; } /// /// Element id of this ListBox's linked scrollbar (dat property 0x72; e.g. the /// Character page's ListBox 0x100001FA names scrollbar 0x100001FB). 0 when the /// dat authors no scrollbar reference. A page controller resolves this id against /// the imported tree and sets the found 's /// to — the same linkage /// ChatWindowController wires for the chat transcript. /// public uint ScrollbarElementId { get; } /// The wrapped viewport's pixel scroll model — link a page controller's /// resolved scrollbar () to this. Creates the /// viewport on first access (see class doc — dormancy). public UiScrollable Scroll => Viewport.Scroll; /// Total stacked row height in px — the same value 's /// content extent uses. 0 while dormant (no viewport created yet). public int ContentHeight => _viewport?.ContentHeight ?? 0; /// Row height for the scroll model's line-scroll quantum. Set once template /// heights are known; defaults to the viewport's own default (16px). Safe to set /// before the viewport exists — the value is applied once it's created. public int LineHeight { get => _viewport?.LineHeight ?? _pendingLineHeight; set { _pendingLineHeight = value; if (_viewport is not null) _viewport.LineHeight = value; } } /// /// Instantiates a row template's subtree (its LayoutDesc DID + element id) into a /// built . Null (the default) means /// cannot build rows yet. /// public Func? TemplateResolver { get; set; } public UiTemplateListBox( ElementInfo info, Func resolve, IReadOnlyList templates, uint scrollbarElementId) : base(info, resolve) { Templates = templates; ScrollbarElementId = scrollbarElementId; } /// Retail ListBox rows never come from static dat children — see class doc. public override bool ConsumesDatChildren => true; /// The lazily-created row viewport, or null before the first row is added. /// Exposed for #372's regression test (the viewport must fill this ListBox, not /// collapse to 0×0). internal UiScrollablePanel? ViewportForTest => _viewport; private UiScrollablePanel Viewport { get { if (_viewport is null) { // #372: the viewport MUST start filling this ListBox. It is created // lazily (post-Build, during a page controller's Bind), so it misses // the Build-time sizing that authored static children get. With // Left|Top|Right|Bottom fill-anchors but a 0×0 initial rect, the // anchor system captures a degenerate baseline (mR = parentW - (0+0) // = parentW) and ComputeAnchoredRect then keeps it 0×0 forever // (w = parentW - mR - mL = 0) — a 0-tall viewport makes // UiScrollablePanel.LayoutScrollableChildren cull every row, so every // ListBox-backed Options tab renders BLANK (Gameplay, which has no // viewport, was the only tab that worked). Seeding the viewport to // this ListBox's current size makes the fill-anchor baseline correct // (mR = parentW - (0 + parentW) = 0) and self-maintaining as the // ListBox re-anchors each frame. The ListBox is a static dat child // sized at Build, so its extent is already authored here. _viewport = new UiScrollablePanel { Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom, LineHeight = _pendingLineHeight, Width = Width, Height = Height, }; base.AddChild(_viewport); // #412-class fix (2026-08-16, overnight hover/UI round, Batch A bug 2): // #372's seed above only fixed the 0×0 collapse for a ListBox whose OWN // size never changes after the viewport is created. It does NOT hold for // the Options panel's real mount: this ListBox (0x10000200 etc.) is a // DAT-imported element carrying its own retail four-edge UiLayoutPolicy // (UIElement::UpdateForParentSizeChange @0x00462640), and a page // controller's Bind (which lazily creates this viewport, calling // AddItemFromTemplateList) runs BEFORE the tree's first real draw frame — // i.e. before ANY ApplyAnchor pass has ever run. The Options tab-host's // page slot (298×575 authored) is taller than its actual 300×362 mounted // container, so on the FIRST draw frame the slot's LayoutPolicy shrinks // it top-down (e.g. to ~298×337), and THIS ListBox — also LayoutPolicy- // driven, recomputed fresh every call, no capture-staleness of its own — // shrinks right behind it (e.g. to ~282×297) in the SAME frame, BEFORE // its per-child loop ever reaches the viewport below it. The viewport // above was seeded at BIND time against the ListBox's PRE-shrink size // (276×560) but its own legacy Left|Top|Right|Bottom anchor baseline is // only CAPTURED lazily, on ITS first ApplyAnchor call — which lands AFTER // the ListBox has already shrunk in that same frame. That capture then // measures a NEGATIVE bottom margin (parentH(297) - (0+560) = -263) which // ComputeAnchoredRect's stretch math preserves forever (h = parentH - mB - // mT = 297 - (-263) - 0 = 560): the viewport is permanently locked at its // ORIGINAL oversized height, clipping its rows to a bound retail never // actually gave it on screen. Every row past the real ~297px stays // "visible" per LayoutScrollableChildren's cull test and paints straight // through the footer and past the window's real bottom edge — the exact // "dozens of rows below the window frame" symptom (#412-class report: // Full Screen/Sync/Screen Brightness/Adaptive Degrade/quality dropdowns // drawing outside the panel). Forcing the capture to happen NOW, while // Width/Height still exactly equal the ListBox's CURRENT (pre-shrink, but // zero-margin) size, makes the captured margins (0,0,0,0) instead of // negative — ComputeAnchoredRect then tracks whatever height the ListBox // ACTUALLY ends up at after its own LayoutPolicy runs, on every frame // after this one, exactly like #372 intended. _viewport.CaptureCurrentAnchorBaseline(); } return _viewport; } } /// /// Retail UIElement_ListBox::AddItemFromTemplateList(m_pOptionBox, index, /// nullptr): resolves Templates[index] through /// and appends the built subtree as the next row, stacked below the previous one. /// Lazily creates the internal viewport on the FIRST successful call (see class doc — /// dormancy). Returns the built row widget, or null when is /// out of range, no resolver is wired, or the resolver produced nothing. /// public UiElement? AddItemFromTemplateList(int index) { if (index < 0 || index >= Templates.Count) return null; Func? resolver = TemplateResolver; if (resolver is null) return null; UiTemplateListEntry entry = Templates[index]; UiElement? row = resolver(entry.TemplateLayoutId, entry.TemplateElementId); if (row is null) return null; return AddPrebuiltRow(row); } /// /// AP-195 (Campaign OP slice OP5): stacks an ALREADY-BUILT row using the exact /// same "below the previous row, then grow ContentHeight" placement /// uses internally, WITHOUT resolving it /// through first. For a row whose own final size /// is not known until AFTER it is fully populated — the Chat tab's five /// filter blocks, which self-size to fit /// their N rows (retail's own CreateChildren tail, /// docs/architecture/retail-divergence-register.md AP-195) — a page /// controller resolves the block directly through the SAME /// delegate (bypassing /// 's own resolve step), calls /// SetDefaultValue + N AddChild on it (growing its /// to its final, fully-populated extent), THEN /// calls this method to insert it. Because the row is placed at its FINAL height /// instead of its authored pre-build extent, this viewport's own /// — and every row placed AFTER /// it — accounts for the real size from the start; no retroactive reflow of /// already-placed siblings is needed (this class has no such reflow mechanism — /// each row's is fixed at insertion time). This is /// literally "reuse UiTemplateListBox's stacking," the disposition AP-195 itself /// names as an acceptable alternative to a full ListBox-unification pass. /// public UiElement AddPrebuiltRow(UiElement row) { ArgumentNullException.ThrowIfNull(row); UiScrollablePanel viewport = Viewport; row.Left = 0f; row.Top = viewport.ContentHeight; viewport.AddChild(row); return row; } /// /// Removes the dynamically-owned suffix after /// without disturbing the retained prefix. This is the structural seam used by /// optional Config-page extensions whose schema can change at runtime: retail's /// authored prefix remains mounted, while only extension rows are detached. /// Existing scroll is preserved and clamped by . /// public void RemoveTail(int retainedItemCount) { int count = _viewport?.Children.Count ?? 0; if (retainedItemCount < 0 || retainedItemCount > count) throw new ArgumentOutOfRangeException(nameof(retainedItemCount)); if (_viewport is null || retainedItemCount == count) return; for (int i = count - 1; i >= retainedItemCount; i--) _viewport.RemoveChild(_viewport.Children[i]); } /// Current number of materialized row widgets. Reading this does /// not wake a dormant list box. public int ItemCount => _viewport?.Children.Count ?? 0; /// /// Campaign FA slice FA3: removes every row previously added via /// /, resetting /// to 0 AND the scroll position to 0 /// ( calls /// Scroll.SetScrollY(0) — fix-round blast SF-5: the ORIGINAL doc here /// only mentioned the height reset) — the "Gap found" prerequisite lane A/D /// flagged (docs/research/2026-08-11-fa-panel-structure.md §6.6: "no Flush, no /// selection model, no per-row instance-id"). Needed for a poll-and-rebuild /// binding (Friends/Squelch read-only lists) where the row COUNT can shrink /// between refreshes — alone only ever /// grows the stack. A no-op, never allocating the viewport, when the box is /// still dormant (no row has ever been added) — mirrors every other dormancy /// guard on this class (see class doc). /// /// /// Diverges from the sibling it shares a /// name with (fix-round blast SF-5): that method does NOT touch scroll /// position. For a poll-and-rebuild Friends/Squelch list, this means a user /// scrolled partway into a long roster is yanked back to the top on every /// server-side change (a friend's online status flips, etc.) — a real, /// user-visible UX cost once is actually /// wired (it now is — see ). /// Not fixed here: doing so would require this class to preserve/re-clamp /// the offset across a rebuild whose row COUNT can shrink, which is a /// bigger behavior change than a fix-round doc correction should make /// unasked; flagged for whoever revisits Friends/Squelch scrolling next. /// /// public void Flush() => _viewport?.ClearContent(); /// /// FA3 carry-forward 1 (folded into Campaign FA slice FA4): like /// , but preserves the CURRENT scroll offset instead /// of resetting it to 0. 's ClearContent zeroes /// 's position — fine for /// Friends/Squelch (revision-polled rebuild while visible, no mid-scroll /// disturbance concern documented) but wrong for a roster whose rebuild /// can be driven by a per-vitals-tick revision bump: resetting to the /// top on every incoming 0x02C0 would fight the user's own /// scrolling the instant they touch the fellowship list. /// only calls this method /// when the member SET actually changed (join/leave/disband) — a plain /// vitals/stat refresh updates the existing rows' bound widgets in place /// and never touches the ListBox structure at all, so this path is the /// rare case, not the common one. The restored offset is clamped against /// the STALE (pre-rebuild) content height at the moment of the call; /// re-clamps it /// against the fresh height on the very next draw, before anything is /// ever painted with a stale bound — the same "clamp now, correct at /// next layout" contract /// already relies on for every ordinary /// call. A no-op (same as ) while dormant. /// public void FlushPreservingScroll() { if (_viewport is null) return; int savedScrollY = _viewport.Scroll.ScrollY; _viewport.ClearContent(); _viewport.Scroll.SetScrollY(savedScrollY); } }