Opus dual-lens review of CT6 (ec50455a) found 1 blocker, 4 should-fix, 5
notes. All applied:
BLOCKER B1 — the shared gmPanelUI host (0x100005FE) IS retail's own
outer window frame, not a content element: its authored 310/372/310/1000
already include the 5px bevel on every side. RetailWindowFrame.Mount was
adding the NineSlice wrapper's OWN 10px chrome inset on top of that
already-chrome-inclusive source, clamping MinWidth to 320 while the
window's actual mounted outer width stayed 310 — silently below its own
minimum until RetailWindowManager.ResizeTo forcibly widened it despite
ResizeX=false. Fixed with a new
RetailWindowFrame.Options.DatConstraintSourceIsOuterFrame opt-out
(chrome inset = 0 for constraint resolution only, value stays
DAT-sourced); MountCharacter sets it true. Mounted clamp is now exactly
the host's four raw values: width fixed 310, height 372..1000. Added a
mount-time invariant (throws if the mounted outer extent falls outside
its own just-computed clamp) that would have caught this at the first
test run.
S4 (campaign-lead ruling) — the window must MOUNT at retail's authored
default, outer 372 (content 362, matching the host's own content parent
0x10000180), not 0x2100002E's own 300x600 content-authoring canvas
(which produced a stale 610px default pre-fix: 600 + 10 chrome inset).
372 is exactly the host's own authored MinHeight — retail opens at its
resize floor and can only be dragged taller. MountCharacter now sets
ContentHeight=362f explicitly. At this default the 9 attribute/vital
rows (180px) overflow the 160px list immediately — retail-correct, not
a regression.
S2 — 0x1000023E and 0x10000533 both author property 0x79
(HideWhenDisabled) TRUE (fixture-verified: BoolValue=true on both). A
fitting list HIDES the scrollbar entirely; it does not draw a full-track
"disabled" thumb. The code was already correct; four wrong descriptions
(plan ledger, CharacterStatController comment, CT7 script, test comment)
are corrected, plus a new IsPresentationVisible assertion pair in the
resize test.
S3 — CharacterTitlesController's `if (listBox.LayoutPolicy is null)`
Anchors fallback was unreachable on both the real DAT and the fixture
(0x10000532/0x10000539 both author HasOriginalParentSize=true, so
LayoutPolicy is always assigned). Deleted; added an InstalledDatFact pin
guarding the deletion against DAT drift.
N4 — renamed NineSlice_ChatShapedConstraints_... to
NineSlice_ContentShapedConstraints_InsetArithmeticClampsProgrammaticResize
(it tested inset arithmetic on a content-shaped source, not chat's real
contract) and added a true chat-contract pin mounting Chrome=Imported
with chat's real 300/100/2000/2000 constraints, asserting no inset
applies.
N5 — corrected the "nothing inferred, no register row" sentences in the
ground-truth doc and plan ledger: they were false pre-fix (the mounted
clamp WAS an inferred double-counted composition); true now that B1
removes the composition.
CT7 script §4 rewritten with exact clamps (no "≈"), the corrected
default-overflow scrollbar behavior, and an absolute starting-height
statement.
Verified: full hermetic solution suite green (15,441 tests, Release,
Lane exclusions per the release gate), InstalledDat lane green across
the whole solution (414 tests, ACDREAM_RUN_INSTALLED_DAT_TESTS=1,
Status!=KnownFailure) including two new pins
(TitlesListAndPage_AuthorHasOriginalParentSize,
Imported_ChatContract_ClampsAtAuthoredBoundsWithNoChromeInset).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
448 lines
21 KiB
C#
448 lines
21 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using AcDream.Runtime;
|
|
|
|
namespace AcDream.App.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Campaign CT slice CT3 (2026-08-24): binds the character window's Titles
|
|
/// page (LayoutDesc <c>0x2100002E</c>, element <c>0x10000539</c> —
|
|
/// <c>gmCharacterTitleUI</c>) to CT2's <see cref="RuntimeCharacterTitleState"/>
|
|
/// owner through the standard <see cref="UiTemplateListBox"/>/
|
|
/// <see cref="UiScrollbar"/>/<see cref="UiButton"/> classes only — no bespoke
|
|
/// widgets, matching every other social/options row-list page in this
|
|
/// codebase (<see cref="SocialFriendsPageController"/>,
|
|
/// <see cref="SocialFellowshipPageController"/>,
|
|
/// <see cref="SocialAllegiancePageController"/>).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Rows.</b> <c>AddTitleToList @0x0049A840</c> resolves each row's text
|
|
/// through <c>CharacterTitleTable::GetCharacterTitleFromID</c> (ported as
|
|
/// <see cref="CharacterTitleResolver"/>, CT2) and inserts it SORTED
|
|
/// (<c>FindSortedInsertPosition @0x0049A760</c> — an ordinal string sort on
|
|
/// the resolved display text). This port rebuilds the full sorted row set on
|
|
/// every change (<see cref="RebuildRows"/>) rather than performing a true
|
|
/// positional splice: <see cref="UiTemplateListBox"/> has no insert-at-index
|
|
/// primitive, and no other <see cref="UiTemplateListBox"/> consumer in this
|
|
/// codebase needs one either (Friends/Squelch/Fellowship/Allegiance/chargen
|
|
/// skills/the Options tabs all rebuild-on-change the same way) — the
|
|
/// resulting VISIBLE order is retail-exact even though the underlying
|
|
/// mechanism is "rebuild," not "splice." <c>AddTitleToList</c> itself
|
|
/// early-outs (<c>@0x0049a873</c>/<c>@0x0049a914</c>) before ever reaching
|
|
/// the insert — retail NEVER creates a row for id 0 or for an id
|
|
/// <c>GetCharacterTitleFromID</c> fails to resolve, so <see cref="RebuildRows"/>
|
|
/// skips those ids entirely rather than falling back to a placeholder row
|
|
/// text (CT3 fix round — the "Unknown" literal belongs ONLY to the
|
|
/// display-title text below, never to a row).
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Selection + highlight.</b> Retail's <c>InfoRegion::SetState</c>
|
|
/// mechanism (confirmed for the sibling stat rows by CT1's SEALED VERDICT)
|
|
/// applies <c>SetState(selected ? 6 : 1)</c> directly to the row element —
|
|
/// state 6 is <see cref="UiButtonStateMachine.Highlight"/>. The title row
|
|
/// template (<c>0x10000536</c>) authors that exact Highlight state
|
|
/// (<c>0x06001AAF</c>) alongside its DirectState background
|
|
/// (<c>0x06004CCA</c>), so this controller uses the row's own
|
|
/// <see cref="IUiDatStateful.TrySetRetailState"/> — no synthesized color
|
|
/// swap, unlike pages whose row template lacks a state-based highlight.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Selection lifetime (CT3 fix round — BLOCKER).</b> <c>Refresh
|
|
/// @0x0049abc0</c> itself calls <c>SetSelectedItem(nullptr, 1)</c>
|
|
/// (<c>@0x0049ac5a</c>) UNCONDITIONALLY, before it repopulates the list —
|
|
/// every code path that reaches <c>Refresh()</c> drops the current
|
|
/// selection outright, regardless of whether the previously-selected title
|
|
/// id is still earned. <c>Refresh()</c> runs on BOTH
|
|
/// <see cref="RuntimeCharacterTitleState.TableReplaced"/> (<c>0x0029</c>)
|
|
/// and <see cref="RuntimeCharacterTitleState.DisplayTitleChanged"/> (the
|
|
/// display-title half of <c>0x002B</c>), so <see cref="OnTableReplaced"/>
|
|
/// and <see cref="OnDisplayTitleChanged"/> both clear
|
|
/// <see cref="_selectedTitleId"/> before rebuilding/re-highlighting.
|
|
/// <c>RecvNotice_AddCharacterTitle @0x0049a990</c> (the add half of
|
|
/// <c>0x002B</c>, <see cref="OnTitleAdded"/>) is a DIFFERENT retail method
|
|
/// that splices one row into <c>mTitleList</c> without ever touching
|
|
/// <c>m_pSelectedItem</c> — selection SURVIVES a title add. Concretely:
|
|
/// after the user sets a display title and ACE echoes <c>0x002B</c>, the
|
|
/// previously-highlighted row goes dark and the Set-as-Display button
|
|
/// re-ghosts, exactly like retail — but earning a brand-new title while a
|
|
/// row is selected leaves that selection alone.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The "Set as Display Title" button (<c>0x10000535</c>).</b>
|
|
/// <c>UpdateButtons @0x0049A500</c> (CORRECTED per the campaign plan's CT1
|
|
/// fix round): Ghosted (state 0xD) UNLESS a row is SELECTED whose title id
|
|
/// DIFFERS from the CURRENT display title; no selection is the Ghosted case,
|
|
/// not the enabled one. A click sends
|
|
/// <c>CM_Social::Event_SetDisplayCharacterTitle</c> (CT2's
|
|
/// <see cref="RuntimeCharacterTitleState"/> command seam) — wire only, no
|
|
/// local mutation; the ghost gate itself makes an already-current selection
|
|
/// unreachable from the UI, so the click handler's own defensive re-check is
|
|
/// belt-and-braces, not the primary guard.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Display-title text (<c>0x1000052F</c>).</b> <c>Refresh @0x0049abc0</c>
|
|
/// shows the resolved current display title, or retail's hardcoded literal
|
|
/// <c>"Unknown"</c> when the id does not resolve — refreshed on BOTH
|
|
/// <see cref="RuntimeCharacterTitleState.TableReplaced"/> (retail's own
|
|
/// <c>RecvNotice_UpdateCharacterTitleTable</c> unconditionally calls
|
|
/// <c>Refresh()</c> on every <c>0x0029</c>, CT2 review anchor 1) and
|
|
/// <see cref="RuntimeCharacterTitleState.DisplayTitleChanged"/>.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The two page captions (<c>0x1000052E</c>/<c>0x10000531</c>).</b> Left
|
|
/// untouched by this controller — <see cref="LayoutImporter.BuildText"/>
|
|
/// already resolves every element's authored <c>StringInfo</c> caption at
|
|
/// import time (<c>ResolveAuthoredString</c>), the SAME mechanism every
|
|
/// other DAT-authored label in this window already relies on, so no
|
|
/// controller-side string lookup is needed or added here.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class CharacterTitlesController : IDisposable
|
|
{
|
|
public const uint CurrentDisplayTitleTextId = 0x1000052Fu;
|
|
public const uint TitleListBoxId = 0x10000532u;
|
|
public const uint SetDisplayButtonId = 0x10000535u;
|
|
|
|
/// <summary>The row template's own text child (<c>0x10000536</c>'s
|
|
/// single Type-0xC child) — CT1 ground truth §3.</summary>
|
|
private const uint RowTextId = 0x10000537u;
|
|
|
|
/// <summary>Retail's hardcoded fallback literal (<c>Refresh
|
|
/// @0x0049abc0</c>) for a display title id that does not resolve —
|
|
/// ported verbatim, not a StringTable key (CT2 review anchor 3).</summary>
|
|
private const string UnknownTitleText = "Unknown";
|
|
|
|
private readonly record struct Row(UiElement Root, uint TitleId);
|
|
|
|
private readonly RuntimeCharacterTitleState _titles;
|
|
private readonly Func<uint, string?> _resolveTitle;
|
|
private readonly Func<uint, RuntimeCommandResult> _sendSetTitle;
|
|
private readonly UiTemplateListBox _listBox;
|
|
private readonly UiText? _displayText;
|
|
private readonly UiButton? _setDisplayButton;
|
|
private readonly List<Row> _rows = new();
|
|
private uint? _selectedTitleId;
|
|
private bool _disposed;
|
|
|
|
private CharacterTitlesController(
|
|
RuntimeCharacterTitleState titles,
|
|
Func<uint, string?> resolveTitle,
|
|
Func<uint, RuntimeCommandResult> sendSetTitle,
|
|
UiTemplateListBox listBox,
|
|
UiText? displayText,
|
|
UiButton? setDisplayButton)
|
|
{
|
|
_titles = titles;
|
|
_resolveTitle = resolveTitle;
|
|
_sendSetTitle = sendSetTitle;
|
|
_listBox = listBox;
|
|
_displayText = displayText;
|
|
_setDisplayButton = setDisplayButton;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Binds the Titles page's list box, scrollbar, display-title text, and
|
|
/// Set-as-Display button under <paramref name="layoutRoot"/> (the
|
|
/// character window's imported tree — the Titles page's element ids are
|
|
/// unique client-wide, so no page-scoped search is needed, unlike the
|
|
/// multi-tab social panel's row families). Returns null (logging why)
|
|
/// when the list box itself is missing — every other element is
|
|
/// optional so a partial import still gets what it can.
|
|
/// </summary>
|
|
/// <param name="resolveTitle">CT2's <see cref="CharacterTitleResolver.Resolve(uint)"/>
|
|
/// method group in production; a delegate (not the concrete DAT-backed
|
|
/// class) so this controller stays hermetically testable without a live
|
|
/// <c>IDatReaderWriter</c>.</param>
|
|
public static CharacterTitlesController? Bind(
|
|
UiElement layoutRoot,
|
|
RuntimeCharacterTitleState titles,
|
|
Func<uint, string?> resolveTitle,
|
|
Func<uint, uint, UiElement?> templateResolver,
|
|
Func<uint, RuntimeCommandResult> sendSetTitle)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(layoutRoot);
|
|
ArgumentNullException.ThrowIfNull(titles);
|
|
ArgumentNullException.ThrowIfNull(resolveTitle);
|
|
ArgumentNullException.ThrowIfNull(templateResolver);
|
|
ArgumentNullException.ThrowIfNull(sendSetTitle);
|
|
|
|
if (UiElement.FindDescendant(layoutRoot, TitleListBoxId) is not UiTemplateListBox listBox)
|
|
{
|
|
Console.WriteLine(
|
|
$"[D.2b] CharacterTitlesController: ListBox 0x{TitleListBoxId:X8} not " +
|
|
"found — the Titles page will not populate.");
|
|
return null;
|
|
}
|
|
listBox.TemplateResolver = templateResolver;
|
|
// The row template (0x10000536) authors a 270x24 box (CT1 ground
|
|
// truth §3); UiTemplateListBox's own DefaultLineHeight is 16, which
|
|
// would desync wheel/line scroll from the actual row pitch
|
|
// (CharacterManagementUiController.cs:463 sets its own row height
|
|
// the same way for the same reason).
|
|
listBox.LineHeight = 24;
|
|
// CT6 (2026-08-25): the Titles list (authored H=455 inside the
|
|
// 575px page — CT1 ground truth §3) shrinks/grows with the window
|
|
// the same way CharacterStatController's attribute/skill list does.
|
|
// CT6 fix round (S3 correction): the former Anchors fallback here
|
|
// ("if LayoutPolicy is null") was DEAD CODE — both 0x10000532 (this
|
|
// ListBox) and its page container 0x10000539 author
|
|
// HasOriginalParentSize=true in the real DAT (pinned by
|
|
// CharacterPanelLiveDatTests.TitlesListAndPage_AuthorHasOriginalParentSize),
|
|
// so LayoutImporter/DatWidgetFactory ALWAYS assigns a real
|
|
// LayoutPolicy to this element and the fallback branch never ran on
|
|
// either the installed DAT or the committed fixture. The actual
|
|
// reflow mechanism is that authored LayoutPolicy stretching with the
|
|
// mounted content's height — deleted rather than left as
|
|
// unreachable/misleading compatibility code. UiTemplateListBox's own
|
|
// internal viewport (created lazily inside AddItemFromTemplateList)
|
|
// already carries the #372-class eager-baseline-capture fix, so once
|
|
// the ListBox itself reflows, its scrollbar (bound below) picks up
|
|
// the new content/view relationship for free.
|
|
|
|
uint scrollbarElementId = listBox.ScrollbarElementId;
|
|
UiElement? scrollbarElement = scrollbarElementId == 0
|
|
? null
|
|
: UiElement.FindDescendant(layoutRoot, scrollbarElementId);
|
|
if (scrollbarElement is UiScrollbar scrollbar)
|
|
scrollbar.Model = listBox.Scroll;
|
|
else
|
|
Console.WriteLine(
|
|
$"[D.2b] CharacterTitlesController: scrollbar 0x{scrollbarElementId:X8} " +
|
|
"not found — the Titles list will not scroll.");
|
|
|
|
UiText? displayText =
|
|
UiElement.FindDescendant(layoutRoot, CurrentDisplayTitleTextId) as UiText;
|
|
UiButton? setDisplayButton =
|
|
UiElement.FindDescendant(layoutRoot, SetDisplayButtonId) as UiButton;
|
|
|
|
var controller = new CharacterTitlesController(
|
|
titles, resolveTitle, sendSetTitle, listBox, displayText, setDisplayButton);
|
|
controller.WireButton();
|
|
|
|
titles.TableReplaced += controller.OnTableReplaced;
|
|
titles.TitleAdded += controller.OnTitleAdded;
|
|
titles.DisplayTitleChanged += controller.OnDisplayTitleChanged;
|
|
|
|
controller.RebuildRows();
|
|
// Bind-time refresh (CT3 fix round NOTE 6): retail itself only
|
|
// shows "Unknown" until the first notice arrives (nothing runs
|
|
// Refresh() before Refresh() is first called), but ACE always sends
|
|
// 0x0029 at SendSelf before this panel can even open, so the
|
|
// pre-notice "Unknown" frame is unreachable in live play. Refreshing
|
|
// at bind time instead keeps a window RE-mount (tab re-open, panel
|
|
// rebuild) consistent with whatever the table already holds, rather
|
|
// than flashing "Unknown" for one frame before the next notice.
|
|
controller.RefreshDisplayText();
|
|
controller.RefreshButtonGhost();
|
|
return controller;
|
|
}
|
|
|
|
private void WireButton()
|
|
{
|
|
if (_setDisplayButton is null) return;
|
|
_setDisplayButton.OnClick = () =>
|
|
{
|
|
// Belt-and-braces re-check (CT2 review anchor 2): retail's real
|
|
// guard is the Ghosted state itself — UiButton refuses to raise
|
|
// OnClick while !Enabled — so this branch is normally
|
|
// unreachable from a real click, but a direct-call test (or a
|
|
// stray event) must still send nothing while ghosted, and never
|
|
// wait for a confirmation ACE does not send when re-setting the
|
|
// already-current title.
|
|
if (_selectedTitleId is not uint id || id == _titles.DisplayTitleId)
|
|
return;
|
|
_sendSetTitle(id);
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>0x0029 CharacterTitle</c> — retail's own <c>Refresh()</c> is
|
|
/// unconditional here (CT2 review anchor 1), and <c>UnPack</c> always
|
|
/// rebuilds <c>mTitleList</c> from scratch. BLOCKER fix (CT3 fix round):
|
|
/// <c>Refresh</c> also calls <c>SetSelectedItem(nullptr, 1)</c>
|
|
/// (<c>@0x0049ac5a</c>) unconditionally, BEFORE it repopulates — so the
|
|
/// selection is cleared here regardless of whether the previously
|
|
/// selected id is still earned, not merely dropped when it disappears.
|
|
/// </summary>
|
|
private void OnTableReplaced()
|
|
{
|
|
ClearSelection();
|
|
RebuildRows();
|
|
RefreshDisplayText();
|
|
RefreshButtonGhost();
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>0x002B UpdateTitle</c>, add half — CT2's F1 fix already dedupes
|
|
/// this event to genuine new memberships only (a repeat add fires no
|
|
/// event at all), so every firing here is a real new row.
|
|
/// <c>RecvNotice_AddCharacterTitle @0x0049a990</c> splices the one new
|
|
/// row into <c>mTitleList</c> without ever touching
|
|
/// <c>m_pSelectedItem</c> (unlike <c>Refresh</c>'s unconditional
|
|
/// clear) — selection deliberately survives a title add.
|
|
/// </summary>
|
|
private void OnTitleAdded(uint titleId)
|
|
{
|
|
RebuildRows();
|
|
RefreshButtonGhost();
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>0x002B UpdateTitle</c>, display half — this is the other trigger
|
|
/// for retail's <c>Refresh()</c> (CT2 review anchor 1), so it carries
|
|
/// the same unconditional <c>SetSelectedItem(nullptr, 1)</c>
|
|
/// (<c>@0x0049ac5a</c>) as <see cref="OnTableReplaced"/>. This handler
|
|
/// does not call <see cref="RebuildRows"/> (the row SET is unchanged —
|
|
/// only the display title moved), so it re-applies highlights directly
|
|
/// to actually dark out the previously-selected row.
|
|
/// </summary>
|
|
private void OnDisplayTitleChanged(uint titleId)
|
|
{
|
|
ClearSelection();
|
|
ApplyRowHighlights();
|
|
RefreshDisplayText();
|
|
RefreshButtonGhost();
|
|
}
|
|
|
|
/// <summary><c>Refresh @0x0049abc0</c>'s <c>SetSelectedItem(nullptr,
|
|
/// 1)</c> (<c>@0x0049ac5a</c>) — clears the tracked selection only; the
|
|
/// caller is responsible for re-applying row highlights and the button
|
|
/// ghost state afterward.</summary>
|
|
private void ClearSelection() => _selectedTitleId = null;
|
|
|
|
/// <summary>
|
|
/// Full sorted rebuild — see the class remarks for why this port
|
|
/// rebuilds rather than performing retail's literal single-row
|
|
/// positional insert. Preserves scroll position
|
|
/// (<see cref="UiTemplateListBox.FlushPreservingScroll"/>). Callers that
|
|
/// mirror retail's unconditional <c>Refresh()</c> selection clear
|
|
/// (<see cref="OnTableReplaced"/>) call <see cref="ClearSelection"/>
|
|
/// themselves before this runs; the check below is a defensive
|
|
/// fallback for any other caller (<see cref="OnTitleAdded"/> included)
|
|
/// so a selection can never point at a row that no longer exists.
|
|
/// </summary>
|
|
private void RebuildRows()
|
|
{
|
|
_listBox.FlushPreservingScroll();
|
|
_rows.Clear();
|
|
|
|
// AddTitleToList @0x0049A840 early-outs (@0x0049a873/@0x0049a914):
|
|
// retail never creates a row for id 0 or for an id
|
|
// GetCharacterTitleFromID fails to resolve — "Unknown" is the
|
|
// display-title text's OWN fallback (RefreshDisplayText), never a
|
|
// row's (CT3 fix round — was previously ported backwards).
|
|
// A3/CT2 doc warning: EarnedTitleIds allocates a fresh array per
|
|
// read — safe here (a UI refresh call site, not a per-frame poll).
|
|
var candidates = new List<(uint Id, string Text)>();
|
|
foreach (uint id in _titles.EarnedTitleIds)
|
|
{
|
|
if (id == 0) continue;
|
|
string? text = _resolveTitle(id);
|
|
if (text is null) continue;
|
|
candidates.Add((id, text));
|
|
}
|
|
// FindSortedInsertPosition @0x0049A760: ordinal string sort on the
|
|
// resolved display text. OrderBy is a STABLE sort (unlike
|
|
// List<T>.Sort) so equal-text rows keep retail's insert-after-
|
|
// equals order; ties are broken by title id for full determinism.
|
|
List<(uint Id, string Text)> sorted = candidates
|
|
.OrderBy(static c => c.Text, StringComparer.Ordinal)
|
|
.ThenBy(static c => c.Id)
|
|
.ToList();
|
|
|
|
foreach ((uint id, string text) in sorted)
|
|
{
|
|
UiElement? row = _listBox.AddItemFromTemplateList(0);
|
|
if (row is null) continue;
|
|
|
|
if (row is UiDatElement datRow)
|
|
{
|
|
// Generic Type-3 container fallback (DatWidgetFactory) —
|
|
// "generic decoration; behavioral widgets opt back in" (its
|
|
// own class doc). Same page-opt-in shape
|
|
// CharacterCreationSkillsPage uses for its selectable rows.
|
|
datRow.ClickThrough = false;
|
|
uint capturedId = id;
|
|
datRow.OnClick = () => SelectRow(capturedId);
|
|
}
|
|
|
|
if (UiElement.FindDescendant(row, RowTextId) is UiText rowText)
|
|
{
|
|
// Build the line array once per text change and capture it
|
|
// — LinesProvider runs every draw, so a `=> [new Line(...)]`
|
|
// literal would allocate a fresh array every frame
|
|
// (pattern: CharacterCreationSkillsPage.cs:829). DefaultColor
|
|
// is the row template's own authored font color, not a
|
|
// hardcoded white.
|
|
UiText.Line[] lines = [new UiText.Line(text, rowText.DefaultColor)];
|
|
rowText.LinesProvider = () => lines;
|
|
}
|
|
|
|
_rows.Add(new Row(row, id));
|
|
}
|
|
|
|
if (_selectedTitleId is uint selected && !_rows.Exists(r => r.TitleId == selected))
|
|
_selectedTitleId = null;
|
|
|
|
ApplyRowHighlights();
|
|
}
|
|
|
|
private void SelectRow(uint titleId)
|
|
{
|
|
if (_disposed) return;
|
|
_selectedTitleId = titleId;
|
|
ApplyRowHighlights();
|
|
RefreshButtonGhost();
|
|
}
|
|
|
|
/// <summary>Retail <c>InfoRegion::SetState(selected ? 6 : 1)</c> — the
|
|
/// row's OWN authored Highlight/DirectState media swap, not a
|
|
/// synthesized color (see class remarks).</summary>
|
|
private void ApplyRowHighlights()
|
|
{
|
|
foreach (Row row in _rows)
|
|
{
|
|
if (row.Root is IUiDatStateful stateful)
|
|
{
|
|
stateful.TrySetRetailState(
|
|
row.TitleId == _selectedTitleId
|
|
? UiButtonStateMachine.Highlight
|
|
: UiButtonStateMachine.Normal);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void RefreshDisplayText()
|
|
{
|
|
if (_displayText is null) return;
|
|
string text = _resolveTitle(_titles.DisplayTitleId) ?? UnknownTitleText;
|
|
// Cached array, authored color — same reasoning as the row text
|
|
// above (CT3 fix round).
|
|
UiText.Line[] lines = [new UiText.Line(text, _displayText.DefaultColor)];
|
|
_displayText.LinesProvider = () => lines;
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>UpdateButtons @0x0049A500</c> (CORRECTED — campaign plan CT1 fix
|
|
/// round): Ghosted UNLESS a row is selected whose title id DIFFERS from
|
|
/// the current display title. No selection is the Ghosted case.
|
|
/// </summary>
|
|
private void RefreshButtonGhost()
|
|
{
|
|
if (_setDisplayButton is null) return;
|
|
bool shouldGhost = _selectedTitleId is not uint id || id == _titles.DisplayTitleId;
|
|
_setDisplayButton.TrySetRetailState(
|
|
shouldGhost ? UiButtonStateMachine.Ghosted : UiButtonStateMachine.Normal);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
_titles.TableReplaced -= OnTableReplaced;
|
|
_titles.TitleAdded -= OnTitleAdded;
|
|
_titles.DisplayTitleChanged -= OnDisplayTitleChanged;
|
|
}
|
|
}
|