feat(ui): FA3 -- mount the social panel shell (Friends/Allegiance/Fellowship/Squelch)
Campaign FA slice FA3: retail's four-tab social panel (LayoutDesc
0x2100006E slot 0x1000018F, RetailPanelCatalog id 12), built on the OP3
OptionsPanelController recipe -- Type-8 tab host, ActivateTabBehavior,
per-page scoped controllers.
- SocialPanelController mounts the tab host and wires the close button;
F3 (ToggleAllegiancePanel) and F4 (ToggleFellowshipPanel) open the
panel and switch to their own tab, sharing the same gmPanelUI
one-active-panel exclusivity every sibling main panel already has.
- The live-DAT tab table CORRECTS the coordinator addendum's x-order
guess: button 0x1000028C ("Allegiance") pairs with page 0x10000291 and
is the authored DEFAULT entry, not Friends -- each button's own page
id and its own P0x57 (matching the F3/F4 ActionMap ids on the
Allegiance/Fellowship pages specifically) both corroborate the real
pairing. See SocialPanelController's class doc for the full table.
- SocialFellowshipPageController swaps the two authored empty/full
frames (0x1000026B/0x10000275) on RuntimeFellowshipState's
IsInFellowship -- both frames' full containment (name box, create
button, checkboxes vs. roster list, six buttons) was confirmed by the
live-mount probe, so a single Visible toggle per frame is the whole
swap (closes lane-A unknown U6).
- SocialAllegiancePageController hides the monarch/patron blocks and
blanks their name text to a literal space when
RuntimeAllegianceState.Snapshot.HasProfile is false, using SCOPED
FindDescendant lookups (the panel authors 0x10000492 twice, once per
block).
- SocialFriendsPageController/SocialSquelchPageController bind their
ListBoxes read-only to RuntimeCommunicationState's existing J4.1
Friends/Squelch owners (names only), rebuilding on revision change.
Their action buttons are honest INERT (D1) -- register row AD-79.
- UiTemplateListBox gains Flush() (lane A/D's "Gap found" prerequisite)
so a poll-and-rebuild list can shrink between refreshes.
- RetailPanelCatalog.SocialPanel = 12, byte-verified from the live slot's
own P0x10000029; listed in Mounted only (no toolbar button -- lane A
§6.1: the open path is keyboard-only).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
b560f415cd
commit
0a9ca2f1f9
12 changed files with 854 additions and 1 deletions
112
src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs
Normal file
112
src/AcDream.App/UI/Layout/SocialAllegiancePageController.cs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AcDream.Runtime;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign FA slice FA3: the Allegiance page's empty-state presentation —
|
||||
/// the ONLY behavior this SHELL slice owns for this page (live monarch/
|
||||
/// patron/vassal population, swear/break/kick + confirmations, and the
|
||||
/// per-member online-state dimming are FA5 scope).
|
||||
///
|
||||
/// <para>
|
||||
/// Retail has no frame swap for Allegiance (unlike Fellowship) — instead it
|
||||
/// hides the monarch/patron blocks and blanks their name text to a literal
|
||||
/// space per-block, gated on whether each relationship exists
|
||||
/// (docs/research/2026-08-11-fa-panel-structure.md §4.5). FA3's contract
|
||||
/// simplifies this to the single gate this campaign's Runtime owner
|
||||
/// actually exposes today: <see cref="RuntimeAllegianceSnapshot.HasProfile"/>
|
||||
/// — no allegiance push has landed this generation. When a profile HAS
|
||||
/// arrived, this controller leaves both blocks visible with their
|
||||
/// build-time (empty) text rather than fabricate monarch/patron content —
|
||||
/// that population is FA5's job.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Element ids confirmed by the FA3 live-mount probe:
|
||||
/// <c>0x10000255</c> monarch-field container, <c>0x1000025A</c>
|
||||
/// patron-field container, <c>0x10000257</c> monarch-name text (inside the
|
||||
/// monarch field), <c>0x1000025C</c> patron-name text (inside the patron
|
||||
/// field). Both lookups are SCOPED to the allegiance page root — the panel
|
||||
/// also authors <c>0x10000492</c> (the XP-passed-up text) TWICE, once under
|
||||
/// each block, so a flat lookup anywhere in this subsystem would risk
|
||||
/// picking the wrong instance (§6 DISCIPLINE, the campaign-OP
|
||||
/// <c>0x10000211</c>-in-two-layouts lesson).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SocialAllegiancePageController
|
||||
{
|
||||
private static readonly IReadOnlyList<UiText.Line> BlankLine =
|
||||
[new UiText.Line(" ", System.Numerics.Vector4.One)];
|
||||
|
||||
private static readonly IReadOnlyList<UiText.Line> NoLines = [];
|
||||
|
||||
private readonly UiElement _monarchField;
|
||||
private readonly UiElement _patronField;
|
||||
private readonly UiText? _monarchName;
|
||||
private readonly UiText? _patronName;
|
||||
private readonly Func<RuntimeAllegianceSnapshot> _snapshot;
|
||||
|
||||
private SocialAllegiancePageController(
|
||||
UiElement monarchField,
|
||||
UiElement patronField,
|
||||
UiText? monarchName,
|
||||
UiText? patronName,
|
||||
Func<RuntimeAllegianceSnapshot> snapshot)
|
||||
{
|
||||
_monarchField = monarchField;
|
||||
_patronField = patronField;
|
||||
_monarchName = monarchName;
|
||||
_patronName = patronName;
|
||||
_snapshot = snapshot;
|
||||
}
|
||||
|
||||
public static SocialAllegiancePageController? Bind(
|
||||
UiElement pageRoot,
|
||||
Func<RuntimeAllegianceSnapshot> snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pageRoot);
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
|
||||
if (UiElement.FindDescendant(pageRoot, 0x10000255u) is not { } monarchField
|
||||
|| UiElement.FindDescendant(pageRoot, 0x1000025Au) is not { } patronField)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[D.2b] SocialAllegiancePageController: monarch/patron field "
|
||||
+ "containers (0x10000255/0x1000025A) not found — allegiance page "
|
||||
+ "will not present its empty state.");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Scoped to each block — 0x10000257/0x1000025C are unique per-block,
|
||||
// but the lookup discipline matters equally here (see class doc).
|
||||
UiText? monarchName = UiElement.FindDescendant(monarchField, 0x10000257u) as UiText;
|
||||
UiText? patronName = UiElement.FindDescendant(patronField, 0x1000025Cu) as UiText;
|
||||
if (monarchName is null)
|
||||
Console.WriteLine("[D.2b] SocialAllegiancePageController: monarch name text 0x10000257 not found.");
|
||||
if (patronName is null)
|
||||
Console.WriteLine("[D.2b] SocialAllegiancePageController: patron name text 0x1000025C not found.");
|
||||
|
||||
var controller = new SocialAllegiancePageController(
|
||||
monarchField, patronField, monarchName, patronName, snapshot);
|
||||
controller.Tick();
|
||||
return controller;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-reads the live snapshot and applies the FA3 empty-state gate. Cheap
|
||||
/// — called every frame from <see cref="SocialPanelController.Tick"/> (same
|
||||
/// reasoning as <see cref="SocialFellowshipPageController.Tick"/>).
|
||||
/// </summary>
|
||||
public void Tick()
|
||||
{
|
||||
bool hasProfile = _snapshot().HasProfile;
|
||||
_monarchField.Visible = hasProfile;
|
||||
_patronField.Visible = hasProfile;
|
||||
|
||||
IReadOnlyList<UiText.Line> lines = hasProfile ? NoLines : BlankLine;
|
||||
if (_monarchName is not null) _monarchName.LinesProvider = () => lines;
|
||||
if (_patronName is not null) _patronName.LinesProvider = () => lines;
|
||||
}
|
||||
}
|
||||
78
src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs
Normal file
78
src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
using System;
|
||||
using AcDream.Runtime;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign FA slice FA3: the Fellowship page's empty-state frame swap —
|
||||
/// the ONLY behavior this SHELL slice owns for this page (roster rows,
|
||||
/// vitals, create dialog, and the option row un-dims are FA4 scope).
|
||||
///
|
||||
/// <para>
|
||||
/// Retail <c>gmFellowshipUI::Update @0x0048F440</c>
|
||||
/// (docs/research/2026-08-11-fa-panel-structure.md §4.5):
|
||||
/// <c>m_pNotInAFellowshipFrame</c> (<c>0x1000026B</c>) shows and
|
||||
/// <c>m_pInAFellowshipFrame</c> (<c>0x10000275</c>) hides when
|
||||
/// <c>m_pFellowship == null</c>, and vice-versa. Both are AUTHORED sibling
|
||||
/// containers of the fellowship page (<c>0x10000292</c>) — confirmed by the
|
||||
/// FA3 live-mount probe: <c>0x1000026B</c> holds the name-entry box, Create
|
||||
/// button, and all three visible option checkboxes; <c>0x10000275</c> holds
|
||||
/// the roster list and all six member-management buttons. A single
|
||||
/// <see cref="UiElement.Visible"/> toggle on each container is therefore the
|
||||
/// WHOLE empty-state swap; no per-child hiding is needed (closes lane-A
|
||||
/// unknown U6).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SocialFellowshipPageController
|
||||
{
|
||||
private readonly UiElement _notInFellowshipFrame;
|
||||
private readonly UiElement _inFellowshipFrame;
|
||||
private readonly Func<RuntimeFellowshipSnapshot> _snapshot;
|
||||
|
||||
private SocialFellowshipPageController(
|
||||
UiElement notInFellowshipFrame,
|
||||
UiElement inFellowshipFrame,
|
||||
Func<RuntimeFellowshipSnapshot> snapshot)
|
||||
{
|
||||
_notInFellowshipFrame = notInFellowshipFrame;
|
||||
_inFellowshipFrame = inFellowshipFrame;
|
||||
_snapshot = snapshot;
|
||||
}
|
||||
|
||||
public static SocialFellowshipPageController? Bind(
|
||||
UiElement pageRoot,
|
||||
Func<RuntimeFellowshipSnapshot> snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pageRoot);
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
|
||||
if (UiElement.FindDescendant(pageRoot, 0x1000026Bu) is not { } notIn
|
||||
|| UiElement.FindDescendant(pageRoot, 0x10000275u) is not { } inFellowship)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[D.2b] SocialFellowshipPageController: empty/full frame pair "
|
||||
+ "(0x1000026B/0x10000275) not found — fellowship page will not "
|
||||
+ "swap its empty state.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var controller = new SocialFellowshipPageController(notIn, inFellowship, snapshot);
|
||||
controller.Tick();
|
||||
return controller;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-reads the live snapshot and applies retail's frame swap. Cheap (two
|
||||
/// bool writes) — called every frame from
|
||||
/// <see cref="SocialPanelController.Tick"/> rather than gated on
|
||||
/// page-shown/revision, since <see cref="UiElement.Visible"/> has no
|
||||
/// data-driven provider mechanism of its own (unlike <see cref="UiText.LinesProvider"/>,
|
||||
/// which is polled by the render loop already).
|
||||
/// </summary>
|
||||
public void Tick()
|
||||
{
|
||||
bool inFellowship = _snapshot().IsInFellowship;
|
||||
_notInFellowshipFrame.Visible = !inFellowship;
|
||||
_inFellowshipFrame.Visible = inFellowship;
|
||||
}
|
||||
}
|
||||
94
src/AcDream.App/UI/Layout/SocialFriendsPageController.cs
Normal file
94
src/AcDream.App/UI/Layout/SocialFriendsPageController.cs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Social;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign FA slice FA3, D1: the Friends page's read-only roster — names
|
||||
/// only, bound directly to the J4.1 <see cref="FriendsState"/> owner
|
||||
/// (<c>RuntimeCommunicationState.Friends</c>). Rebuilds on
|
||||
/// <see cref="FriendsState.Revision"/> change; polled every frame from
|
||||
/// <see cref="SocialPanelController.Tick"/> (cheap — a single
|
||||
/// <see langword="long"/> comparison when nothing changed).
|
||||
///
|
||||
/// <para>
|
||||
/// <b>D1 — inert actions.</b> This campaign implements no Friends
|
||||
/// add/remove/appear-offline wire (the trivially-pinnable bar D1 sets was
|
||||
/// not met for this family — see the plan's D1 decision). The three
|
||||
/// buttons (<c>0x10000514</c>/<c>0x10000515</c>/<c>0x10000516</c>) and the
|
||||
/// "Appear Offline" checkbox (<c>0x1000052C</c>) are left AUTHORED and
|
||||
/// CLICKABLE with no handler — <see cref="DatWidgetFactory"/> already
|
||||
/// builds every button/checkbox with a null <c>OnClick</c> by default, so
|
||||
/// no explicit "leave unbound" code is needed here. See this commit's
|
||||
/// single register row covering the whole D1 Friends/Squelch inert-actions
|
||||
/// scope (one row, not one per button).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Row template <c>0x2100005D</c>/<c>0x10000519</c> (FA3 live-mount probe);
|
||||
/// <see cref="SocialPanelRowText.FindDeepest"/> resolves the row's own
|
||||
/// name-text leaf — see that class's doc for why the deepest match is used
|
||||
/// (gmFriendsUI is outside this campaign's decompiled scope).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SocialFriendsPageController
|
||||
{
|
||||
private const uint ListBoxId = 0x10000517u;
|
||||
|
||||
private readonly UiTemplateListBox _listBox;
|
||||
private readonly FriendsState _friends;
|
||||
private long _lastRevision = long.MinValue;
|
||||
|
||||
private SocialFriendsPageController(UiTemplateListBox listBox, FriendsState friends)
|
||||
{
|
||||
_listBox = listBox;
|
||||
_friends = friends;
|
||||
}
|
||||
|
||||
public static SocialFriendsPageController? Bind(
|
||||
UiElement pageRoot,
|
||||
FriendsState friends,
|
||||
Func<uint, uint, UiElement?> templateResolver)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pageRoot);
|
||||
ArgumentNullException.ThrowIfNull(friends);
|
||||
ArgumentNullException.ThrowIfNull(templateResolver);
|
||||
|
||||
if (UiElement.FindDescendant(pageRoot, ListBoxId) is not UiTemplateListBox listBox)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[D.2b] SocialFriendsPageController: ListBox 0x{ListBoxId:X8} not "
|
||||
+ "found — Friends page will not populate.");
|
||||
return null;
|
||||
}
|
||||
listBox.TemplateResolver = templateResolver;
|
||||
|
||||
var controller = new SocialFriendsPageController(listBox, friends);
|
||||
controller.Refresh();
|
||||
return controller;
|
||||
}
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
long revision = _friends.Revision;
|
||||
if (revision == _lastRevision) return;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
_lastRevision = _friends.Revision;
|
||||
_listBox.Flush();
|
||||
foreach (FriendEntry friend in _friends.Snapshot())
|
||||
{
|
||||
UiElement? row = _listBox.AddItemFromTemplateList(0);
|
||||
if (row is null) continue;
|
||||
if (SocialPanelRowText.FindDeepest(row) is { } text)
|
||||
{
|
||||
string name = friend.Name;
|
||||
text.LinesProvider = () => [new UiText.Line(name, Vector4.One)];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
229
src/AcDream.App/UI/Layout/SocialPanelController.cs
Normal file
229
src/AcDream.App/UI/Layout/SocialPanelController.cs
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
using System;
|
||||
using AcDream.Core.Social;
|
||||
using AcDream.Runtime;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Mounts retail's four-tab social panel — Friends / Allegiance /
|
||||
/// Fellowship / Squelch — LayoutDesc <c>0x2100006E</c> slot
|
||||
/// <c>0x1000018F</c>, <see cref="AcDream.App.UI.RetailPanelCatalog"/> id
|
||||
/// <b>12</b>. Campaign FA slice FA3 (2026-08-12), built on the OP3
|
||||
/// <see cref="OptionsPanelController"/> recipe (Type-8 tab host,
|
||||
/// <see cref="UiTabPanel.ActivateTabBehavior"/>, per-page scoped
|
||||
/// controllers).
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Coordinator addendum</b>
|
||||
/// (<c>docs/research/2026-08-11-fa-panel-structure.md</c> §10) closed lane-A
|
||||
/// unknown U2: Fellowship and Allegiance are NOT two separate
|
||||
/// <c>gmPanelUI</c> siblings — they are two of FOUR pages of ONE tabbed
|
||||
/// panel, the same authored Type-8 tab-host class OP2/OP3 already ported.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Tab-table pairing — CORRECTS the coordinator addendum's guess.</b> The
|
||||
/// addendum inferred the button→page pairing from authored x-order (assuming
|
||||
/// button/page ids increase in Friends/Allegiance/Fellowship/Squelch order).
|
||||
/// The FA3 fixture dump (<c>social_panel_2100006E_1000018F.json</c>) and the
|
||||
/// live-mount probe (<see cref="SocialFriendsPageController"/> et al.'s
|
||||
/// sibling test, <c>SocialPanelLiveMountProbeTests</c>) read the real
|
||||
/// authored <c>0x2E</c> tab table and found a DIFFERENT pairing — each
|
||||
/// button's own caption confirms its real page:
|
||||
/// <code>
|
||||
/// button 0x1000028C ("Allegiance") -> page 0x10000291 (Allegiance) DEFAULT
|
||||
/// button 0x1000028E ("Fellowship") -> page 0x10000292 (Fellowship)
|
||||
/// button 0x10000512 ("Friends") -> page 0x10000513 (Friends)
|
||||
/// button 0x1000053B ("Squelch") -> page 0x1000054A (Squelch)
|
||||
/// </code>
|
||||
/// The panel's authored DEFAULT tab is therefore <b>Allegiance</b>, not
|
||||
/// Friends — consistent with each page's own <c>P0x57</c> property, which
|
||||
/// (unlike the panel root's own unconsumed <c>P0x57</c>, see below) DOES
|
||||
/// line up with real retail ActionMap ids: the Allegiance page carries
|
||||
/// <c>P0x57 = 0x1000000E</c> (== <c>ToggleAllegiancePanel</c>, F3) and the
|
||||
/// Fellowship page carries <c>P0x57 = 0x1000000F</c> (==
|
||||
/// <c>ToggleFellowshipPanel</c>, F4) — an independent, DAT-authored
|
||||
/// confirmation of which page each keybind should land on. (The Friends and
|
||||
/// Squelch pages ALSO carry their own dedicated action ids, <c>0x10000118</c>
|
||||
/// and <c>0x10000124</c> — neither has a retail-default keybind per lane A's
|
||||
/// keymap sweep, and this campaign does not add one.)
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Close button.</b> <c>0x10000290</c> (Type 1, top-right at
|
||||
/// <c>(276,0) 24x25</c>) — its <c>P0x12 = 0x1000000D</c> matches the panel
|
||||
/// root's own <c>P0x57 = 0x1000000D</c>. Neither value corresponds to a
|
||||
/// per-tab open action (retail's <c>ID_InputMap_ToggleChatEntry</c> uses
|
||||
/// <c>0x1000000D</c> as an unrelated input-map CONTEXT id, not an action —
|
||||
/// <c>RetailActionMap.cs:97,161</c>); no
|
||||
/// <c>GetAttribute_Enum(this, 0x57, …)</c> read site exists for either panel
|
||||
/// in the binary (lane A §6.1), so this property is authored but UNCONSUMED
|
||||
/// by retail code, same UNVERIFIED status the Options-panel research
|
||||
/// recorded. This controller does not interpret the value — it simply wires
|
||||
/// the close button by its confirmed element id, exactly like
|
||||
/// <see cref="OptionsPanelController"/>'s own <c>CloseButtonId</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SocialPanelController : IRetainedPanelController
|
||||
{
|
||||
/// <summary>The floating host LayoutDesc the tab panel is resolved through.</summary>
|
||||
public const uint HostLayoutId = 0x2100006Eu;
|
||||
|
||||
/// <summary>The social panel's slot within <see cref="HostLayoutId"/>'s
|
||||
/// shared <c>gmPanelUI</c> page stack — also its
|
||||
/// <see cref="AcDream.App.UI.RetailPanelCatalog"/> panel id (12)'s element
|
||||
/// identity. Byte-verified against the live installed DATs (coordinator
|
||||
/// addendum §10, <c>FaPanelSlotProbeTests</c>).</summary>
|
||||
public const uint SlotElementId = 0x1000018Fu;
|
||||
|
||||
// Page slot ids (FA3 live-mount probe — coordinator addendum §10 table).
|
||||
private const uint FriendsPageId = 0x10000513u;
|
||||
private const uint AllegiancePageId = 0x10000291u;
|
||||
private const uint FellowshipPageId = 0x10000292u;
|
||||
private const uint SquelchPageId = 0x1000054Au;
|
||||
|
||||
/// <summary>Close (X) button — see class doc.</summary>
|
||||
private const uint CloseButtonId = 0x10000290u;
|
||||
|
||||
/// <summary>Callback delegates + live-state accessors this controller
|
||||
/// wires the four pages and the close button to.</summary>
|
||||
public sealed record Callbacks(
|
||||
Action Toggle,
|
||||
Func<RuntimeFellowshipSnapshot> FellowshipSnapshot,
|
||||
Func<RuntimeAllegianceSnapshot> AllegianceSnapshot,
|
||||
FriendsState Friends,
|
||||
SquelchState Squelch,
|
||||
Func<uint, uint, UiElement?> TemplateResolver);
|
||||
|
||||
private readonly UiTabPanel _tabPanel;
|
||||
private readonly SocialFellowshipPageController? _fellowship;
|
||||
private readonly SocialAllegiancePageController? _allegiance;
|
||||
private readonly SocialFriendsPageController? _friends;
|
||||
private readonly SocialSquelchPageController? _squelch;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Root element of the imported panel (the tab host itself —
|
||||
/// this widget IS a <see cref="UiTabPanel"/>).</summary>
|
||||
public UiElement Root => _tabPanel;
|
||||
|
||||
/// <summary>The underlying tab-control widget, for callers that need
|
||||
/// direct tab-switch access (e.g. tests).</summary>
|
||||
public UiTabPanel TabPanel => _tabPanel;
|
||||
|
||||
private SocialPanelController(
|
||||
UiTabPanel tabPanel,
|
||||
SocialFellowshipPageController? fellowship,
|
||||
SocialAllegiancePageController? allegiance,
|
||||
SocialFriendsPageController? friends,
|
||||
SocialSquelchPageController? squelch)
|
||||
{
|
||||
_tabPanel = tabPanel;
|
||||
_fellowship = fellowship;
|
||||
_allegiance = allegiance;
|
||||
_friends = friends;
|
||||
_squelch = squelch;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bind an imported <see cref="HostLayoutId"/>/<see cref="SlotElementId"/>
|
||||
/// layout to live behavior — the SAME "import via the host slot, then
|
||||
/// Build+Bind" shape <see cref="OptionsPanelController.Bind"/> uses.
|
||||
/// </summary>
|
||||
public static SocialPanelController? Bind(ImportedLayout layout, Callbacks callbacks)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
ArgumentNullException.ThrowIfNull(callbacks);
|
||||
|
||||
if (layout.Root is not UiTabPanel tabPanel)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[D.2b] SocialPanelController.Bind: root did not build as UiTabPanel "
|
||||
+ $"(actual type {layout.Root.GetType().Name}) — social panel will not open.");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (layout.FindElement(CloseButtonId) is UiButton close)
|
||||
close.OnClick = callbacks.Toggle;
|
||||
else
|
||||
Console.WriteLine(
|
||||
$"[D.2b] SocialPanelController: close button 0x{CloseButtonId:X8} "
|
||||
+ "not found in the built layout — its handler was not wired.");
|
||||
|
||||
UiElement? fellowshipPage = UiElement.FindDescendant(tabPanel, FellowshipPageId);
|
||||
UiElement? allegiancePage = UiElement.FindDescendant(tabPanel, AllegiancePageId);
|
||||
UiElement? friendsPage = UiElement.FindDescendant(tabPanel, FriendsPageId);
|
||||
UiElement? squelchPage = UiElement.FindDescendant(tabPanel, SquelchPageId);
|
||||
|
||||
SocialFellowshipPageController? fellowship = fellowshipPage is null
|
||||
? null
|
||||
: SocialFellowshipPageController.Bind(fellowshipPage, callbacks.FellowshipSnapshot);
|
||||
SocialAllegiancePageController? allegiance = allegiancePage is null
|
||||
? null
|
||||
: SocialAllegiancePageController.Bind(allegiancePage, callbacks.AllegianceSnapshot);
|
||||
SocialFriendsPageController? friends = friendsPage is null
|
||||
? null
|
||||
: SocialFriendsPageController.Bind(friendsPage, callbacks.Friends, callbacks.TemplateResolver);
|
||||
SocialSquelchPageController? squelch = squelchPage is null
|
||||
? null
|
||||
: SocialSquelchPageController.Bind(squelchPage, callbacks.Squelch, callbacks.TemplateResolver);
|
||||
|
||||
if (fellowshipPage is null)
|
||||
Console.WriteLine($"[D.2b] SocialPanelController: Fellowship page 0x{FellowshipPageId:X8} not found.");
|
||||
if (allegiancePage is null)
|
||||
Console.WriteLine($"[D.2b] SocialPanelController: Allegiance page 0x{AllegiancePageId:X8} not found.");
|
||||
if (friendsPage is null)
|
||||
Console.WriteLine($"[D.2b] SocialPanelController: Friends page 0x{FriendsPageId:X8} not found.");
|
||||
if (squelchPage is null)
|
||||
Console.WriteLine($"[D.2b] SocialPanelController: Squelch page 0x{SquelchPageId:X8} not found.");
|
||||
|
||||
return new SocialPanelController(tabPanel, fellowship, allegiance, friends, squelch);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Activates the tab-switching behavior (idempotent). Must run AFTER
|
||||
/// <see cref="Bind"/> so the page-shown wiring above is already in
|
||||
/// place — same ordering constraint as
|
||||
/// <see cref="OptionsPanelController.ActivateTabs"/>. The authored
|
||||
/// default entry (Allegiance — see class doc) activates here.
|
||||
/// </summary>
|
||||
public void ActivateTabs() => _tabPanel.ActivateTabBehavior();
|
||||
|
||||
/// <summary>F3 <c>ToggleAllegiancePanel</c>'s tab-switch half — the
|
||||
/// panel-visibility half is <see cref="RetailUiRuntime"/>'s job (the
|
||||
/// gmPanelUI one-active-panel exclusivity, shared with every sibling
|
||||
/// panel via <see cref="RetailPanelUiController.RegisterMainPanel"/>).</summary>
|
||||
public void ShowAllegiance() => _tabPanel.SwitchTo(AllegiancePageId);
|
||||
|
||||
/// <summary>F4 <c>ToggleFellowshipPanel</c>'s tab-switch half.</summary>
|
||||
public void ShowFellowship() => _tabPanel.SwitchTo(FellowshipPageId);
|
||||
|
||||
/// <summary>True when the Allegiance tab is the active page — lets
|
||||
/// <see cref="RetailUiRuntime.HandleInputAction"/> implement retail's
|
||||
/// Toggle-action close-on-second-press semantics (same shape as
|
||||
/// <c>OpenSpellbook</c>'s own page-aware toggle).</summary>
|
||||
public bool IsShowingAllegiance => _tabPanel.ActivePageElementId == AllegiancePageId;
|
||||
|
||||
/// <summary>True when the Fellowship tab is the active page.</summary>
|
||||
public bool IsShowingFellowship => _tabPanel.ActivePageElementId == FellowshipPageId;
|
||||
|
||||
/// <summary>
|
||||
/// Per-frame poll: the Fellowship/Allegiance empty-state gates (no
|
||||
/// data-driven <see cref="UiElement.Visible"/> provider exists) and the
|
||||
/// Friends/Squelch read-only list rebuilds (revision-gated — cheap when
|
||||
/// nothing changed). Called from <see cref="RetailUiRuntime.Tick"/>
|
||||
/// alongside every other retained panel's own <c>Tick()</c>.
|
||||
/// </summary>
|
||||
public void Tick()
|
||||
{
|
||||
_fellowship?.Tick();
|
||||
_allegiance?.Tick();
|
||||
_friends?.Tick();
|
||||
_squelch?.Tick();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
32
src/AcDream.App/UI/Layout/SocialPanelRowText.cs
Normal file
32
src/AcDream.App/UI/Layout/SocialPanelRowText.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign FA slice FA3: shared row-text lookup for the social panel's
|
||||
/// Friends/Squelch read-only list rows. Both row templates
|
||||
/// (<c>0x2100005D</c>/<c>0x10000519</c> for Friends,
|
||||
/// <c>0x21000060</c>/<c>0x10000541</c> for Squelch — FA3 live-mount probe)
|
||||
/// author the SAME two-deep nested-text shape: a Type-3 row root, one
|
||||
/// Type-0xC text child, and ONE MORE Type-0xC text grandchild at the
|
||||
/// identical rect. Neither <c>gmFriendsUI</c> nor <c>gmSquelchUI</c> is in
|
||||
/// this campaign's decompiled scope (lane A/B/C/D cover only Fellowship/
|
||||
/// Allegiance), so which of the two carries the actual glyph flow is not
|
||||
/// established from retail decomp — the innermost leaf is used here
|
||||
/// (deepest-first is the common "structural wrapper, then label leaf"
|
||||
/// shape elsewhere in this dat format).
|
||||
/// </summary>
|
||||
internal static class SocialPanelRowText
|
||||
{
|
||||
/// <summary>Returns the deepest <see cref="UiText"/> descendant of
|
||||
/// <paramref name="row"/> (pre-order, last match wins), or null if the
|
||||
/// built row contains no text element at all.</summary>
|
||||
public static UiText? FindDeepest(UiElement row)
|
||||
{
|
||||
UiText? found = row as UiText;
|
||||
foreach (UiElement child in row.Children)
|
||||
{
|
||||
if (FindDeepest(child) is { } deeper)
|
||||
found = deeper;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
}
|
||||
94
src/AcDream.App/UI/Layout/SocialSquelchPageController.cs
Normal file
94
src/AcDream.App/UI/Layout/SocialSquelchPageController.cs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Social;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign FA slice FA3, D1: the Squelch page's read-only roster — names
|
||||
/// only, bound directly to the J4.1 <see cref="SquelchState"/> owner
|
||||
/// (<c>RuntimeCommunicationState.Squelch</c>). Lists both squelched
|
||||
/// characters (<see cref="SquelchDatabase.Characters"/>, keyed by the
|
||||
/// character's own display name) and squelched accounts
|
||||
/// (<see cref="SquelchDatabase.Accounts"/>, whose dictionary KEY is the
|
||||
/// account name itself). Rebuilds on <see cref="SquelchState.Revision"/>
|
||||
/// change; polled every frame from <see cref="SocialPanelController.Tick"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>D1 — inert actions.</b> Same disposition as
|
||||
/// <see cref="SocialFriendsPageController"/>: the three buttons
|
||||
/// (<c>0x10000547</c>/<c>0x1000054B</c>/<c>0x1000054C</c>) are left
|
||||
/// AUTHORED and CLICKABLE with no handler. See this commit's single
|
||||
/// register row covering the whole D1 Friends/Squelch inert-actions scope.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Row template <c>0x21000060</c>/<c>0x10000541</c> (FA3 live-mount probe);
|
||||
/// see <see cref="SocialPanelRowText"/> for the name-text resolution note
|
||||
/// (<c>gmSquelchUI</c> is outside this campaign's decompiled scope).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SocialSquelchPageController
|
||||
{
|
||||
private const uint ListBoxId = 0x1000053Eu;
|
||||
|
||||
private readonly UiTemplateListBox _listBox;
|
||||
private readonly SquelchState _squelch;
|
||||
private long _lastRevision = long.MinValue;
|
||||
|
||||
private SocialSquelchPageController(UiTemplateListBox listBox, SquelchState squelch)
|
||||
{
|
||||
_listBox = listBox;
|
||||
_squelch = squelch;
|
||||
}
|
||||
|
||||
public static SocialSquelchPageController? Bind(
|
||||
UiElement pageRoot,
|
||||
SquelchState squelch,
|
||||
Func<uint, uint, UiElement?> templateResolver)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pageRoot);
|
||||
ArgumentNullException.ThrowIfNull(squelch);
|
||||
ArgumentNullException.ThrowIfNull(templateResolver);
|
||||
|
||||
if (UiElement.FindDescendant(pageRoot, ListBoxId) is not UiTemplateListBox listBox)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[D.2b] SocialSquelchPageController: ListBox 0x{ListBoxId:X8} not "
|
||||
+ "found — Squelch page will not populate.");
|
||||
return null;
|
||||
}
|
||||
listBox.TemplateResolver = templateResolver;
|
||||
|
||||
var controller = new SocialSquelchPageController(listBox, squelch);
|
||||
controller.Refresh();
|
||||
return controller;
|
||||
}
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
long revision = _squelch.Revision;
|
||||
if (revision == _lastRevision) return;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
_lastRevision = _squelch.Revision;
|
||||
_listBox.Flush();
|
||||
SquelchDatabase database = _squelch.Snapshot();
|
||||
|
||||
foreach (SquelchInfo character in database.Characters.Values)
|
||||
AddRow(character.Name);
|
||||
foreach (string accountName in database.Accounts.Keys)
|
||||
AddRow(accountName);
|
||||
}
|
||||
|
||||
private void AddRow(string name)
|
||||
{
|
||||
UiElement? row = _listBox.AddItemFromTemplateList(0);
|
||||
if (row is null) return;
|
||||
if (SocialPanelRowText.FindDeepest(row) is { } text)
|
||||
text.LinesProvider = () => [new UiText.Line(name, Vector4.One)];
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue