acdream/src/AcDream.App/UI/UiTemplateListBox.cs
Erik 5bdd0528f1 feat(ui): FA4 -- fellowship page fully live
Roster: SocialFellowshipPageController now builds one row per fellow
from the authored template (0x21000030/0x10000281, live-DAT verified),
diffing the member GUID set on each revision-gated Tick -- an unchanged
set updates every row's bound widgets in place (no ListBox mutation, so
scroll position is untouched by construction); only a real join/leave/
disband triggers a rebuild, via UiTemplateListBox.FlushPreservingScroll
(FA3 carry-forward 1, both the widget-level fix and the controller-level
diff). Health/stamina/mana meters bind Fill+Label; the leader's name
tints gold (lane A's row template has no dedicated leader marker, so
this is a flagged adaptation, not a ported mechanism). Row-click
selection (SelectFellow) drives Dismiss/Leader targeting and the world
selection (SelectionChangeSource.Social).

D4: SocialPanelController now tracks "is the social window shown AND is
Fellowship the active tab" via UiTabPanel.ActivePageChanged +
OnShown/OnHidden, and calls SetPageVisible on every transition, which
sends 0x00A6 (idempotent, no-op while disconnected) -- the prerequisite
ACE gates its 0x02C0 vitals stream on.

Create flow: the inline name field (0x1000026F, an authored Editable
UiField -- live-DAT verified) gates the Create button's enabled state
exactly like retail (empty name = disabled = the whole refusal
mechanism, no separate error text); FellowshipShareXP's live value is
read at click time.

Actions + confirmations: Recruit/Dismiss/Quit/Disband/AssignLeader/
SetOpen all route through DeferredGameRuntimeStateCommands (new
Fellowship* methods) rather than a raw WorldSession send, so Quit
correctly picks up RuntimeFellowshipState's leader hand-off rule.
Button enable states port gmFellowshipUI::UpdateButtons verbatim. The
Open/Close button's caption swaps between the two DAT-resolved strings
cached once at Bind (never per-tick -- DatCollection is not safe to
touch unprotected from the render loop). RetailUiRuntime intercepts a
type-4 confirmation request before it reaches the generic
GameplayConfirmationController: IgnoreFellowshipRequests auto-declines,
FellowshipAutoAcceptRequests auto-accepts, neither set falls through to
the existing dialog machinery unchanged (D6).

D5 display: the per-fellow stats line uses retail's byte-decoded
even-split percentage table verbatim (1.0/.../.3111111/.28, default
0.0); the proportional branch omits the percentage rather than
inventing a formula (no acdream ExperienceToRaiseLevel table exists
yet). Both StringInfo variable substitution (row/stats/vitals text) and
ACCharGenData::FormatName (create-flow name canonicalization) are
unported prerequisites, so row text renders as plain numeric composites
-- register rows AD-80/AD-81 (docs commit).

D7: un-dims IgnoreFellowshipRequests/FellowshipAutoAcceptRequests
(consumed by the D6 auto-decline/accept) and FellowshipShareXP/
FellowshipShareLoot (consumed by Create + the page's own second
checkbox surface) on the Character tab -- 4 of 35 store-only rows
promoted to Live (31 remain dimmed).

Carry-forwards from the FA3 re-review, folded into this slice's
contract:
- UiTemplateListBox.FlushPreservingScroll -- preserves scroll offset
  across a rebuild instead of resetting to 0 (Flush's existing
  contract, unchanged, for Friends/Squelch).
- RowTemplateResolver -- the FA3 caching row-template resolver
  extracted from a MountSocialPanel local function into its own
  hermetically-testable class; now shared by Friends/Squelch/
  Fellowship's row families.
- Friends/Squelch scrollbars now resolve via the built
  UiTemplateListBox.ScrollbarElementId (DAT property 0x72) instead of
  a hardcoded literal, matching ConfigOptionsPageController's own OP6
  precedent.
- The Fellowship roster path never advances its revision latch on a
  partial resolver failure until the NEXT real membership change --
  never a per-frame retry loop.

Live-DAT verified (ACDREAM_PROBE_LIVE_MOUNT=1, extended
SocialPanelLiveMountProbeTests): the name field builds as UiField, all
11 buttons/checkboxes resolve, the row template's 5 checked fields
resolve to the right widget types, every checkbox label/tooltip and the
Open/Close captions resolve to real retail strings ("Open"/"Close"),
and a full production-path Bind() against live DATs produces zero
"not found" warnings.

App tests: +30 (7 UiTemplateListBox/RowTemplateResolver unit tests, 23
SocialFellowshipPageControllerTests covering roster diff/rebuild,
button enable rules, checkbox wiring, create-flow gating, D4
idempotency, and D5 formatting) plus 2 CharacterOptionsPageController
counts updated for the D7 un-dim (35->31 dimmed, 15->19 live).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 04:40:53 +02:00

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