acdream/src/AcDream.App/UI/UiTemplateListBox.cs

359 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using AcDream.App.UI.Layout;
namespace AcDream.App.UI;
/// <summary>
/// Retail <c>UIElement_ListBox</c> (dat class Type <c>5</c>) with an authored row-template
/// list (dat property <c>0x64</c>). Port of <c>AddItemFromTemplateList(index)</c>:
/// instantiates row <paramref name="index"/>'s template — a <see cref="UiTemplateListEntry"/>
/// naming a cross-layout <c>{LayoutDesc DID, element id}</c> pair, per
/// <c>docs/research/2026-08-10-options-panel-structure.md</c> §1.5 — through the SAME
/// import machinery every other retained window uses, and appends it as one row.
///
/// <para>
/// <b>OP2 rework (2026-08-11):</b> derives from <see cref="UiDatElement"/> 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 <see cref="DatWidgetFactory"/> already authors a
/// non-empty <c>0x64</c> 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 (<c>base.AddChild(_viewport)</c>)
/// gave every one of those ten elements a spurious hit-testable
/// <see cref="UiScrollablePanel"/> child plus lost their authored media (the old class
/// derived from bare <see cref="UiPanel"/>, 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 <c>0x64</c> 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 <see cref="UiDatElement"/> fallback. The Options panel's three
/// ListBoxes (Character/Config/Chat, Campaign OP slice OP4+) are the only elements a
/// controller will ever call <see cref="AddItemFromTemplateList"/> against.
/// </para>
///
/// <para>
/// <see cref="ConsumesDatChildren"/> is <c>true</c>: retail ListBox rows come ONLY from
/// <c>AddItemFromTemplateList</c>, 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 — <c>LayoutImporter</c> does not attempt to
/// recurse into any literal dat children this element might (incorrectly) author.
/// </para>
///
/// <para>
/// Row content is stacked inside a lazily-created <see cref="UiScrollablePanel"/> —
/// created on the FIRST successful <see cref="AddItemFromTemplateList"/> 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 <see cref="UiElement.AddChild"/> (the same "resolve → not a UiItemList → build one
/// and attach it directly to the host" pattern <c>EffectsUiController</c>/
/// <c>AppraisalUiController</c>/<c>SpellbookWindowController</c>/
/// <c>CharacterStatController</c> already use for these exact ten elements).
/// <see cref="UiScrollablePanel"/> is sealed, hence composition rather than inheritance —
/// the SAME "controller-built row list" viewport pattern
/// <c>CharacterStatController.RebuildActiveList</c> 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 (<see cref="Scroll"/>) 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 <see cref="UiElement.Top"/> is set to the viewport's
/// current <see cref="UiScrollablePanel.ContentHeight"/> 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).
/// </para>
///
/// <para>
/// <see cref="TemplateResolver"/> is the seam a page controller wires with real DAT
/// access — e.g. <c>(layoutId, elementId) =&gt; LayoutImporter.Build(
/// LayoutImporter.ImportInfos(dats, layoutId, elementId), resolve, datFont,
/// fontResolve, stringResolve)?.Root</c> (the SAME "one selected root from a
/// catalog-style LayoutDesc" overload <c>RetailDialogFactory</c> already uses for the
/// shared dialog catalog — the Options panel's row templates are top-level siblings
/// of the tab control in <c>0x2100002B</c>, structurally identical to that catalog
/// shape). Left null by <c>DatWidgetFactory</c> itself: OP2 ships the mechanism, a
/// page controller (Campaign OP slice OP4+) supplies the resolver once it has a live
/// <c>IDatReaderWriter</c>.
/// </para>
/// </summary>
public sealed class UiTemplateListBox : UiDatElement
{
/// <summary>Retail element id this class was ported for: Type 5 = 5.</summary>
public const uint RetailTypeId = 5u;
private const int DefaultLineHeight = 16;
private UiScrollablePanel? _viewport;
private int _pendingLineHeight = DefaultLineHeight;
/// <summary>The authored row-template list (dat property 0x64), in authored array order.</summary>
public IReadOnlyList<UiTemplateListEntry> Templates { get; }
/// <summary>
/// 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 <see cref="UiScrollbar"/>'s
/// <see cref="UiScrollbar.Model"/> to <see cref="Scroll"/> — the same linkage
/// <c>ChatWindowController</c> wires for the chat transcript.
/// </summary>
public uint ScrollbarElementId { get; }
/// <summary>The wrapped viewport's pixel scroll model — link a page controller's
/// resolved scrollbar (<see cref="ScrollbarElementId"/>) to this. Creates the
/// viewport on first access (see class doc — dormancy).</summary>
public UiScrollable Scroll => Viewport.Scroll;
/// <summary>Total stacked row height in px — the same value <see cref="Scroll"/>'s
/// content extent uses. 0 while dormant (no viewport created yet).</summary>
public int ContentHeight => _viewport?.ContentHeight ?? 0;
/// <summary>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.</summary>
public int LineHeight
{
get => _viewport?.LineHeight ?? _pendingLineHeight;
set
{
_pendingLineHeight = value;
if (_viewport is not null) _viewport.LineHeight = value;
}
}
/// <summary>
/// Instantiates a row template's subtree (its LayoutDesc DID + element id) into a
/// built <see cref="UiElement"/>. Null (the default) means
/// <see cref="AddItemFromTemplateList"/> cannot build rows yet.
/// </summary>
public Func<uint, uint, UiElement?>? TemplateResolver { get; set; }
public UiTemplateListBox(
ElementInfo info,
Func<uint, (uint tex, int w, int h)> resolve,
IReadOnlyList<UiTemplateListEntry> templates,
uint scrollbarElementId)
: base(info, resolve)
{
Templates = templates;
ScrollbarElementId = scrollbarElementId;
}
/// <summary>Retail ListBox rows never come from static dat children — see class doc.</summary>
public override bool ConsumesDatChildren => true;
/// <summary>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).</summary>
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;
}
}
/// <summary>
/// Retail <c>UIElement_ListBox::AddItemFromTemplateList(m_pOptionBox, index,
/// nullptr)</c>: resolves <c>Templates[index]</c> through <see cref="TemplateResolver"/>
/// 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 <paramref name="index"/> is
/// out of range, no resolver is wired, or the resolver produced nothing.
/// </summary>
public UiElement? AddItemFromTemplateList(int index)
{
if (index < 0 || index >= Templates.Count) return null;
Func<uint, uint, UiElement?>? 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);
}
/// <summary>
/// AP-195 (Campaign OP slice OP5): stacks an ALREADY-BUILT row using the exact
/// same "below the previous row, then grow ContentHeight" placement
/// <see cref="AddItemFromTemplateList"/> uses internally, WITHOUT resolving it
/// through <see cref="TemplateResolver"/> first. For a row whose own final size
/// is not known until AFTER it is fully populated — the Chat tab's five
/// <see cref="UiCheckboxBitfield64"/> filter blocks, which self-size to fit
/// their N rows (retail's own <c>CreateChildren</c> tail,
/// <c>docs/architecture/retail-divergence-register.md</c> AP-195) — a page
/// controller resolves the block directly through the SAME
/// <see cref="TemplateResolver"/> delegate (bypassing
/// <see cref="AddItemFromTemplateList"/>'s own resolve step), calls
/// <c>SetDefaultValue</c> + N <c>AddChild</c> on it (growing its
/// <see cref="UiElement.Height"/> 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
/// <see cref="UiScrollablePanel.ContentHeight"/> — 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 <see cref="UiElement.Top"/> 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.
/// </summary>
public UiElement AddPrebuiltRow(UiElement row)
{
ArgumentNullException.ThrowIfNull(row);
UiScrollablePanel viewport = Viewport;
row.Left = 0f;
row.Top = viewport.ContentHeight;
viewport.AddChild(row);
return row;
}
/// <summary>
/// Removes the dynamically-owned suffix after <paramref name="retainedItemCount"/>
/// 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 <see cref="UiScrollablePanel"/>.
/// </summary>
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]);
}
/// <summary>Current number of materialized row widgets. Reading this does
/// not wake a dormant list box.</summary>
public int ItemCount => _viewport?.Children.Count ?? 0;
/// <summary>
/// Campaign FA slice FA3: removes every row previously added via
/// <see cref="AddItemFromTemplateList"/>/<see cref="AddPrebuiltRow"/>, resetting
/// <see cref="ContentHeight"/> to 0 AND the scroll position to 0
/// (<see cref="UiScrollablePanel.ClearContent"/> calls
/// <c>Scroll.SetScrollY(0)</c> — 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 — <see cref="AddItemFromTemplateList"/> 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).
///
/// <para>
/// <b>Diverges from the sibling <see cref="UiItemList.Flush"/> it shares a
/// name with</b> (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 <see cref="ScrollbarElementId"/> is actually
/// wired (it now is — see <see cref="AcDream.App.UI.Layout.SocialFriendsPageController"/>).
/// 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.
/// </para>
/// </summary>
public void Flush() => _viewport?.ClearContent();
/// <summary>
/// FA3 carry-forward 1 (folded into Campaign FA slice FA4): like
/// <see cref="Flush"/>, but preserves the CURRENT scroll offset instead
/// of resetting it to 0. <see cref="Flush"/>'s <c>ClearContent</c> zeroes
/// <see cref="UiScrollablePanel.Scroll"/>'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 <c>0x02C0</c> would fight the user's own
/// scrolling the instant they touch the fellowship list.
/// <see cref="SocialFellowshipPageController"/> 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;
/// <see cref="UiScrollablePanel.LayoutScrollableChildren"/> 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 <see cref="UiScrollablePanel.RecomputeContentHeight"/>
/// already relies on for every ordinary <see cref="AddItemFromTemplateList"/>
/// call. A no-op (same as <see cref="Flush"/>) while dormant.
/// </summary>
public void FlushPreservingScroll()
{
if (_viewport is null) return;
int savedScrollY = _viewport.Scroll.ScrollY;
_viewport.ClearContent();
_viewport.Scroll.SetScrollY(savedScrollY);
}
}