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