using System.Globalization;
using System.Numerics;
using AcDream.Core.CharGen;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.UI.Layout;
///
/// The Skills page (gmCGSkillsPage, root 0x100003d3) —
/// simplified to one flat listbox rather than retail's four-bucket sorted
/// insertion model (InsertEntrySorted/UpdateSkillEntry,
/// Trained/Specialized/UseableUntrained/UnuseableUntrained — register
/// AP-213). Decomp
/// anchors: gmCGSkillsPage::InitializePage @ 0x00481dd0 (listbox
/// 0x100003f7, credits meter 0x100002f3 — imports as button
/// 0x100003f9's own consumed Label, see the ctor comment — info
/// panes 0x100003fb/0x100003fc),
/// gmCGSkillsPage::UpdateCreditsMeter
/// @ 0x004808f0 (credits display is the raw
/// remainingSkillCredits — no formula). The 16 skill ids uncostable
/// in BOTH the heritage's own list and the global SkillTable (retail's own
/// skills listbox never lists them either — CC1's
/// ChargenTableReaderInstalledDatTests) are filtered out via the
/// same two-tier presence check RuntimeCharacterCreationState's
/// TryGetSkillCost uses.
///
///
/// GF-5 fix (Campaign CC gate round 1, Batch A, 2026-08-16):
/// RebuildRows used to require Templates[0]'s resolved root to
/// be a UiButton and treat its own Label as the row's whole content —
/// both wrong. Live-DAT-probe-confirmed against the installed EoR dat and
/// gmCGSkillsPage::DoSkillRecords @ 0x004817e0:
/// Templates[0] (0x100002F4, 3 children) is retail's own
/// bucket-HEADER row (Specialized/Trained/UseableUntrained/
/// UnuseableUntrained — unused by this port's flat-list simplification,
/// AP-213), and the REAL skill row is Templates[1]
/// (0x100002FF, a plain container root, 7 children). Byte-traced
/// through DoSkillRecords' own GetChildRecursive calls +
/// tagSkillRecord's copy-constructor field order
/// (acclient.h struct gmCGSkillsPage::tagSkillRecord):
/// 0x10000301 = the skill NAME (set once at row build, never
/// refreshed — retail has no per-refresh name write either),
/// 0x10000302 = pSkillLevelText (the numeric skill SCORE,
/// CharGenState::GetSkillScore), 0x10000303 =
/// pUpCostText, 0x10000306 = pDownCostText,
/// 0x10000304 = pSkillUpButton (fires
/// IncreaseSkillLevel on plain click,
/// ListenToElementMessage @0x004814c0 case 0x10000304),
/// 0x10000305 = pSkillDownButton (fires
/// DecreaseSkillLevel, same dispatcher's case 0x10000305).
/// Both buttons fire on a PLAIN click, not click-vs-double-click on one
/// shared row — the row now wires exactly that, retiring AP-213's own
/// click-to-advance/double-click-retreat single-button substitution (the
/// row's still-simplified flat-list-vs-four-bucket half is untouched and
/// stays registered).
///
///
///
/// Batch F fixes (Campaign CC gate round 1, 2026-08-16 — R2-4 + review
/// F1/F2): four of R2-4's five sub-items are fixed here; the
/// four-bucket sorted model (R2-4b) is NOT — see the batch report and the
/// AP-213 row for the exact missing data channel (retail's Useable-vs-
/// Unuseable-Untrained split reads SkillBase.MinLevel, which
/// /
/// do not carry today).
///
/// - R2-4a (row selection): a row click (or an arrow click, matching
/// retail's own post-Increase/DecreaseSkillLevel SetSelectedItem(...,
/// 1) re-select) now selects that skill — the row's NAME text swaps to
/// (best-derived "brighter white" per the
/// user's own report + the GF-11b precedent) and the info panes
/// (0x100003fb/0x100003fc) get ShowSkillsText
/// @0x00481250's title (name + score, " (%d)\n") and bonus line
/// ("Training Bonus +5"/"Specialization Bonus +10") — a
/// PARTIAL port: the description (SkillBase._description) and
/// MakeSkillFormula @0x00480e10's computed formula text are not
/// reachable from this page's current data surface; see
/// 's own doc.
/// - R2-4c (scrollbar): the listbox's own authored scrollbar link
/// (, dat
/// property 0x72) is now wired to
/// — the SAME
/// page-level UiScrollbar.Model linkage every other
/// UiTemplateListBox owner uses (no widget change).
/// - Review F1 (cost text): SetSkillText's Untrained down-cost
/// (@0x00480877) and Specialized up-cost (@0x0048067f) are
/// literal "0", unconditional — the prior port rendered blank
/// (null) instead. The <0x3e7 (999) blank gate exists
/// ONLY on the up-cost at Untrained (@0x00480819) and Trained
/// (@0x0048071f); every down-cost write is unconditional
/// (@0x00480877/@0x00480780/@0x004806c1), including
/// Trained's raw iTrainCost even when it would exceed 999.
/// - Review F2 (arrow states): SetSkillText ends every branch
/// driving pSkillUpButton/pSkillDownButton through its own
/// custom Ghosted/Enabled state pair (/
/// — raw ids via
/// , the SAME "authored
/// custom pair" shape as GF-1's Unselected/Selected). Up is gated on
/// remainingSkillCredits vs. the advance cost and is ALWAYS ghosted
/// at Specialized (nothing above it); Down is ALWAYS ghosted at Untrained
/// (nothing below it) and otherwise gated on bUntrainable/
/// bUnspecializable — re-derived from DoSkillRecords's own
/// tagSkillRecord build (@0x00480e40-region) as "the row's OWN
/// effective trained/specialized cost is nonzero" (a free/heritage-granted
/// skill or specialization locks its own down arrow), using the SAME
/// heritage-then-global cost this page already resolves via
/// — no new data needed.
///
///
///
internal sealed class CharacterCreationSkillsPage : IDisposable
{
/// Retail's own row-name id (set once at row build; retail
/// never re-writes it on refresh either — DoSkillRecords'
/// UIElement_Text::SetText(id_2, &var_138) at
/// 0x00481d5d runs OUTSIDE the per-refresh SetSkillText
/// call).
private const uint RowNameTextId = 0x10000301u;
/// tagSkillRecord::pSkillLevelText — the numeric skill
/// SCORE (SetSkillText @0x00480600's
/// CharGenState::GetSkillScore call, "%d" format).
private const uint RowLevelTextId = 0x10000302u;
/// tagSkillRecord::pUpCostText.
private const uint RowUpCostTextId = 0x10000303u;
/// tagSkillRecord::pDownCostText.
private const uint RowDownCostTextId = 0x10000306u;
/// tagSkillRecord::pSkillUpButton —
/// ListenToElementMessage's case 0x10000304 fires
/// IncreaseSkillLevel on a plain click (idMessage==1).
private const uint RowUpButtonId = 0x10000304u;
/// tagSkillRecord::pSkillDownButton — same dispatcher's
/// case 0x10000305 fires DecreaseSkillLevel.
private const uint RowDownButtonId = 0x10000305u;
/// Retail's own custom Ghosted state id for
/// pSkillUpButton/pSkillDownButton (SetSkillText's
/// own SetState(0x1000001a) calls) — distinct from the standard
/// UiButtonStateMachine.Ghosted (13) numbering; the same
/// "authored custom pair, raw retail id" shape as GF-1's
/// Unselected/Selected (0x10000016/0x10000017).
private const uint ArrowGhostedStateId = 0x1000001Au;
/// Retail's own custom Enabled state id for the same two
/// buttons (SetState(0x1000001b)).
private const uint ArrowEnabledStateId = 0x1000001Bu;
/// R2-4a row-selection highlight: pure white. Re-derived from
/// the GF-11b precedent (list-caption color swap Normal
/// (218,167,85) -> Highlight/white (255,255,255) on
/// selection) plus the user's own report ("retail selection turns the
/// row brighter white") absent a skills-row-specific cdb capture — the
/// direction (unselected -> brighter/whiter) is directly evidenced;
/// the exact target RGB is the best available derivation, not a live
/// measurement.
private static readonly Vector4 SelectedNameColor = Vector4.One;
/// One built skill row: the resolved Templates[1]
/// subtree plus the child widgets needs
/// every tick, resolved once at build time rather than re-walked per
/// refresh. is the row's OWN authored
/// (DAT-default) name color, captured at build time so R2-4a's
/// selection highlight can restore it exactly on deselect.
private readonly record struct SkillRow(
UiElement Root,
uint SkillId,
UiText? NameText,
UiText? LevelText,
UiText? UpCostText,
UiText? DownCostText,
UiButton? UpButton,
UiButton? DownButton,
Vector4 UnselectedNameColor);
private readonly CharacterCreationRuntimeBindings _bindings;
private readonly UiTemplateListBox? _list;
private readonly UiButton? _credits;
private readonly UiText? _infoTitle;
private readonly UiText? _infoText;
private readonly List _rows = [];
private uint _lastHeritageId;
private uint? _selectedSkillId;
private bool _rowsBuilt;
private bool _disposed;
internal CharacterCreationSkillsPage(
UiElement pageRoot,
CharacterCreationRuntimeBindings bindings,
Func templateResolver)
{
_bindings = bindings;
_list = UiElement.FindDescendant(pageRoot, 0x100003F7u) as UiTemplateListBox;
if (_list is not null)
_list.TemplateResolver = templateResolver;
// R2-4c (Batch F): wire the listbox's own authored scrollbar (dat
// property 0x72, UiTemplateListBox.ScrollbarElementId) the SAME
// page-level Model linkage every other UiTemplateListBox owner uses
// (ConfigOptionsPageController, SocialFriendsPageController, et
// al.) — no widget change, just resolving the id the importer
// already read and pointing its Model at this listbox's own Scroll.
if (_list is not null
&& _list.ScrollbarElementId != 0
&& UiElement.FindDescendant(pageRoot, _list.ScrollbarElementId) is UiScrollbar scrollbar)
{
scrollbar.Model = _list.Scroll;
}
// Live-DAT probe (CharacterCreationLiveDatTests): the credits meter
// (retail's m_pCreditsMeter, decomp id 0x100002f3) authors as a raw
// dat CHILD of button 0x100003f9, not as a standalone descendant of
// the page root. UiButton.ConsumesDatChildren swallows it before it
// becomes an addressable widget (the same reason UiMeter's overlay
// text needed an explicit carve-out in LayoutImporter) — the
// faithful substitute is the button's own Label, which is exactly
// the mechanism our factory already uses to surface a consumed
// Type-12 child's text (register AD-103).
//
// GF-4a (Campaign CC gate round 1 Batch C): this used to clobber
// the button's authored "Available Skill Credits" caption (retail's
// own m_pCreditsMeter, 0x100002f3, is a SEPARATE widget from the
// caption text — gmCGSkillsPage::InitializePage @0x00481e1c).
// DatWidgetFactory.BuildButton now surfaces that media-less Type-12
// child as UiButton.ValueLabel, coexisting with Label — see
// Refresh below.
_credits = UiElement.FindDescendant(pageRoot, 0x100003F9u) as UiButton;
_infoTitle = UiElement.FindDescendant(pageRoot, 0x100003FBu) as UiText;
_infoText = UiElement.FindDescendant(pageRoot, 0x100003FCu) as UiText;
}
internal void Refresh(
IRuntimeCharacterCreationView view,
RuntimeCharacterCreationSnapshot snapshot)
{
if (!_rowsBuilt || _lastHeritageId != snapshot.HeritageId)
{
RebuildRows(view, snapshot.HeritageId);
_lastHeritageId = snapshot.HeritageId;
_rowsBuilt = true;
}
foreach (SkillRow row in _rows)
RefreshRowValues(row, view, snapshot);
RefreshInfoBox(view, snapshot);
if (_credits is { } credits)
credits.ValueLabel = snapshot.RemainingSkillCredits.ToString(CultureInfo.InvariantCulture);
}
private void RebuildRows(IRuntimeCharacterCreationView view, uint heritageId)
{
foreach (SkillRow row in _rows)
{
if (row.UpButton is not null) row.UpButton.OnClick = null;
if (row.DownButton is not null) row.DownButton.OnClick = null;
if (row.Root is UiDatElement datRoot) datRoot.OnClick = null;
}
_rows.Clear();
_list?.Flush();
// The skill list is rebuilding under a (possibly new) heritage —
// any previously selected skill id may no longer exist as a row.
_selectedSkillId = null;
ClearInfoBox();
if (_list is null
|| _list.Templates.Count < 2
|| _list.TemplateResolver is null
|| !view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage))
{
return;
}
// Templates[1] (0x100002FF) is the REAL skill row — see this
// class's own doc comment for the full byte trace.
UiTemplateListEntry template = _list.Templates[1];
for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++)
{
if (!IsCostable(heritage, view.Options, skillId))
continue;
if (_list.TemplateResolver(template.TemplateLayoutId, template.TemplateElementId)
is not { } rowRoot)
{
continue;
}
_list.AddPrebuiltRow(rowRoot);
UiText? nameText = UiElement.FindDescendant(rowRoot, RowNameTextId) as UiText;
if (nameText is not null)
SetLine(nameText, ItemAppraisalTextFormatter.SkillName((int)skillId));
// Captured AFTER SetLine (which never touches DefaultColor —
// it's read lazily inside the LinesProvider closure) so this is
// the row's own DAT-authored default color, for R2-4a's
// selection highlight to restore on deselect.
Vector4 unselectedColor = nameText?.DefaultColor ?? Vector4.One;
UiText? levelText = UiElement.FindDescendant(rowRoot, RowLevelTextId) as UiText;
UiText? upCostText = UiElement.FindDescendant(rowRoot, RowUpCostTextId) as UiText;
UiText? downCostText = UiElement.FindDescendant(rowRoot, RowDownCostTextId) as UiText;
UiButton? upButton = UiElement.FindDescendant(rowRoot, RowUpButtonId) as UiButton;
UiButton? downButton = UiElement.FindDescendant(rowRoot, RowDownButtonId) as UiButton;
uint capturedSkillId = skillId;
// R2-4a: retail re-selects the row after an arrow click too
// (ListenToElementMessage @0x004814c0's SetSelectedItem(...,1)
// call following IncreaseSkillLevel/DecreaseSkillLevel).
if (upButton is not null)
upButton.OnClick = () => { Advance(capturedSkillId); SelectRow(capturedSkillId); };
if (downButton is not null)
downButton.OnClick = () => { Retreat(capturedSkillId); SelectRow(capturedSkillId); };
// R2-4a: the row-click equivalent of retail's listbox-level
// selection notification (idElement==0x100003f7 &&
// idMessage==4 in ListenToElementMessage) — UiTemplateListBox
// has no generic selection mechanism of its own (see its class
// doc), so this page opts the row in directly. Templates[1]
// (0x100002FF) resolves through DatWidgetFactory's Type-3
// (generic-container) fallback arm to UiDatElement, which
// already carries a page-opt-in OnClick/ClickThrough seam for
// exactly this — "generic decoration; behavioral widgets opt
// back in" (UiDatElement's own doc).
if (rowRoot is UiDatElement datRow)
{
datRow.ClickThrough = false;
datRow.OnClick = () => SelectRow(capturedSkillId);
}
_rows.Add(new SkillRow(
rowRoot, skillId, nameText, levelText, upCostText, downCostText,
upButton, downButton, unselectedColor));
}
}
private void RefreshRowValues(
SkillRow row,
IRuntimeCharacterCreationView view,
RuntimeCharacterCreationSnapshot snapshot)
{
ChargenSkillAdvancementClass level = view.GetSkillLevel(row.SkillId);
(int trainedCost, int specializedCost) = GetCosts(view, snapshot.HeritageId, row.SkillId);
uint score = _bindings.GetSkillScore?.Invoke(row.SkillId, snapshot.Attributes, level) ?? 0u;
if (row.LevelText is { } levelText)
SetLine(levelText, score.ToString(CultureInfo.InvariantCulture));
// Review F1/F2 fix (Batch F): SetSkillText @0x00480600's exact
// per-state cost text + arrow-enable pair — see this class's own
// header doc for the full byte trace of every address cited below.
string upCostText;
string downCostText;
bool upEnabled;
bool downEnabled;
switch (level)
{
case ChargenSkillAdvancementClass.Specialized:
// @0x0048067f: up = literal "0", unconditional (nothing
// above Specialized). @0x004806c1: down = specCost-
// trainCost, UNCONDITIONAL (no 999-blank gate).
// @0x004806fc: up arrow ALWAYS ghosted. @0x0048070c +
// @0x004807f1/@0x004807f4: down arrow enabled iff
// bUnspecializable — re-derived as specializedCost != 0
// (a free/heritage-granted specialization, cost 0, locks
// its own down arrow — DoSkillRecords zeroes
// bUnspecializable exactly there, @0x00480e40 region).
upCostText = "0";
downCostText = (specializedCost - trainedCost).ToString(CultureInfo.InvariantCulture);
upEnabled = false;
downEnabled = specializedCost != 0;
break;
case ChargenSkillAdvancementClass.Trained:
// @0x0048071f: up = specCost-trainCost, blank if >=999.
// @0x00480780: down = trainCost, UNCONDITIONAL (no gate,
// even past 999). @0x004807ce: up arrow enabled iff
// remainingSkillCredits >= specCost-trainCost.
// @0x004807ec + @0x004807f1/@0x004807f4: down arrow
// enabled iff bUntrainable — re-derived as trainedCost != 0
// (same free-skill-locks-the-down-arrow rule, mirrored on
// the trained cost).
upCostText = FormatGatedCost(specializedCost - trainedCost);
downCostText = trainedCost.ToString(CultureInfo.InvariantCulture);
upEnabled = snapshot.RemainingSkillCredits >= specializedCost - trainedCost;
downEnabled = trainedCost != 0;
break;
default:
// Untrained/Inactive. @0x00480819: up = trainCost, blank if
// >=999. @0x00480877: down = literal "0", unconditional.
// @0x004808b3: down arrow ALWAYS ghosted (nothing below
// Untrained). @0x004808d1: up arrow enabled iff
// remainingSkillCredits >= trainCost.
upCostText = FormatGatedCost(trainedCost);
downCostText = "0";
upEnabled = snapshot.RemainingSkillCredits >= trainedCost;
downEnabled = false;
break;
}
if (row.UpCostText is { } upCostTextWidget)
SetLine(upCostTextWidget, upCostText);
if (row.DownCostText is { } downCostTextWidget)
SetLine(downCostTextWidget, downCostText);
row.UpButton?.TrySetRetailState(upEnabled ? ArrowEnabledStateId : ArrowGhostedStateId);
row.DownButton?.TrySetRetailState(downEnabled ? ArrowEnabledStateId : ArrowGhostedStateId);
}
/// The up-cost-only 999 blank gate (< 0x3e7,
/// data_794320 — an empty PStringBase). Never applied to a
/// down-cost or a literal "0" write — see the per-branch citations in
/// .
private static string FormatGatedCost(int cost) =>
cost < 999 ? cost.ToString(CultureInfo.InvariantCulture) : string.Empty;
private static void SetLine(UiText text, string content) =>
text.LinesProvider = () => [new UiText.Line(content, text.DefaultColor)];
/// Same dictionary-presence gate as
/// RuntimeCharacterCreationState.TryGetSkillCost — heritage list
/// first, global SkillTable fallback.
private static bool IsCostable(
ChargenHeritageOptions heritage,
ChargenOptions options,
uint skillId) =>
heritage.SkillCostsBySkillId.ContainsKey(skillId)
|| options.GlobalSkillCostsBySkillId.ContainsKey(skillId);
private static (int Trained, int Specialized) GetCosts(
IRuntimeCharacterCreationView view,
uint heritageId,
uint skillId)
{
if (view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage))
{
if (heritage.SkillCostsBySkillId.TryGetValue(skillId, out ChargenSkillCost cost))
return (cost.NormalCost, cost.PrimaryCost);
}
if (view.Options.GlobalSkillCostsBySkillId.TryGetValue(skillId, out ChargenSkillCost global))
return (global.NormalCost, global.PrimaryCost);
return (0, 0);
}
/// pSkillUpButton click: IncreaseSkillLevel
/// @0x00480ca0 — Untrained/Inactive -> Trained,
/// Trained -> Specialized.
private void Advance(uint skillId)
{
if (_disposed)
return;
ChargenSkillAdvancementClass level = _bindings.View()?.GetSkillLevel(skillId)
?? ChargenSkillAdvancementClass.Inactive;
if (level is ChargenSkillAdvancementClass.Inactive or ChargenSkillAdvancementClass.Untrained)
_bindings.TrainSkill(skillId);
else if (level == ChargenSkillAdvancementClass.Trained)
_bindings.SpecializeSkill(skillId);
}
/// pSkillDownButton click: DecreaseSkillLevel
/// @0x00480d60 — Specialized -> Trained,
/// Trained -> Untrained.
private void Retreat(uint skillId)
{
if (_disposed)
return;
ChargenSkillAdvancementClass level = _bindings.View()?.GetSkillLevel(skillId)
?? ChargenSkillAdvancementClass.Inactive;
if (level == ChargenSkillAdvancementClass.Specialized)
_bindings.TrainSkill(skillId);
else if (level == ChargenSkillAdvancementClass.Trained)
_bindings.UntrainSkill(skillId);
}
///
/// R2-4a: row click / arrow click selection — the port's equivalent of
/// retail's listbox-level SetSelectedItem notification (see
/// 's own wiring doc). Applies the highlight
/// to every row (so the PREVIOUSLY selected row also gets restored to
/// its own ) and refreshes
/// the info panes for the newly selected skill. '
/// View is resolved fresh here, never cached, per
/// feedback_resolve_deferred_funcs_per_call.md.
///
private void SelectRow(uint skillId)
{
if (_disposed)
return;
_selectedSkillId = skillId;
foreach (SkillRow row in _rows)
{
if (row.NameText is { } nameText)
nameText.DefaultColor = row.SkillId == skillId ? SelectedNameColor : row.UnselectedNameColor;
}
if (_bindings.View() is { } view)
RefreshInfoBox(view, view.Snapshot);
}
///
/// gmCGSkillsPage::ShowSkillsText @0x00481250 — writes
/// m_pInfoBoxTitle (0x100003fb) and m_pInfoBoxText
/// (0x100003fc) for the currently selected skill, or clears both
/// when nothing is selected (retail's own arg2==0/lookup-miss
/// arms, both UIElement_Text::ClearAllText). Title is the skill
/// name plus its current score (" (%d)\n", e.g. "Loyalty (5)").
/// Body is level-gated bonus text
/// ("Training Bonus +5"/"Specialization Bonus +10" —
/// TWO spaces before the number, matching the compiled literal
/// verbatim) only.
///
///
/// PARTIAL PORT — see the batch report: retail's body ALSO
/// prepends the skill's DESCRIPTION (SkillBase._description,
/// read via eax_2[7] off the row's own cached
/// tagSkillRecord) and appends
/// MakeSkillFormula @0x00480e10's computed "Formula : ..." text
/// (attribute names + weighted-formula arithmetic, sourced from
/// SkillBase._formula). Neither is reachable from this page's
/// current data surface:
/// carries per-skill COSTS only (never description/formula), and
/// has no resolver for
/// either (unlike ,
/// which already exists for the score). Porting them needs a new
/// binding of that same shape, backed by the global SkillTable — out of
/// this file's edit contract for this batch.
///
///
private void RefreshInfoBox(IRuntimeCharacterCreationView view, RuntimeCharacterCreationSnapshot snapshot)
{
if (_selectedSkillId is not { } skillId)
{
ClearInfoBox();
return;
}
ChargenSkillAdvancementClass level = view.GetSkillLevel(skillId);
uint score = _bindings.GetSkillScore?.Invoke(skillId, snapshot.Attributes, level) ?? 0u;
string name = ItemAppraisalTextFormatter.SkillName((int)skillId);
if (_infoTitle is { } title)
SetLine(title, $"{name} ({score.ToString(CultureInfo.InvariantCulture)})");
if (_infoText is { } text)
{
string bonus = level switch
{
ChargenSkillAdvancementClass.Trained => "Training Bonus +5",
ChargenSkillAdvancementClass.Specialized => "Specialization Bonus +10",
_ => string.Empty,
};
SetLine(text, bonus);
}
}
private void ClearInfoBox()
{
if (_infoTitle is { } title) SetLine(title, string.Empty);
if (_infoText is { } text) SetLine(text, string.Empty);
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
foreach (SkillRow row in _rows)
{
if (row.UpButton is not null) row.UpButton.OnClick = null;
if (row.DownButton is not null) row.DownButton.OnClick = null;
if (row.Root is UiDatElement datRow) datRow.OnClick = null;
}
_rows.Clear();
_list?.Flush();
if (_list is not null)
_list.TemplateResolver = null;
}
}