feat(ui): Campaign OP slice OP5 — the Chat tab
Binds LayoutDesc 0x2100005C through OP2's template-list mechanism and OP3's per-page OptionPage model: the General Options header + two DualHash-linked opacity sliders (Option_DefaultOpacity_Property 0x10000080 / Option_ActiveOpacity_Property 0x10000081, live-apply on drag through RetailWindowOpacityController, defaults read from the installed DAT's DBProperties collection at DID 0x78000001 via ChatOptionsDatDefaults), and the five per-window text-filter blocks (main window 12 rows minus Gameplay, four floaties 13 rows each — the byte-verified authored order cross-checked against the raw gmChatOptionsUI::InitOptions/AddCheckboxBitfield64Option pseudo-C, not just the research doc's own table) writing AcDream.Core.Chat. ChatWindowState directly, the same state CH6's chat windows already read. AP-195 retired: ported both halves left open at the OP2 re-review — the ALL-set LED media swap (new UiButton.FaceFileOverride, driven by the block-level P0x10000082/P0x10000083 sprites now threaded through ElementInfo/DatWidgetFactory) and the CreateChildren self-sizing tail (UiCheckboxBitfield64.Height grows with its stacked row content; the enclosing ListBox reflows around the block's FINAL height via the new UiTemplateListBox.AddPrebuiltRow, reusing the ListBox's own stacking rather than a third stacking path). AP-187 broadened to cover the main window's own filter (previously only the four floaties) and the new live-editing write path. The main chat window's filter (retail window id 8, ChatWindowState id 0) gains its own settings.json persistence (ChatSettings. ChatWindowMainFilter) alongside the pre-existing floaty 1-4 fields; opacity persistence is now wired on every live slider change, not only through the old dev-scaffold Settings panel. Fixture regeneration (ACDREAM_REGENERATE_UI_FIXTURES=1) picked up the new ElementInfo.LedCheckedSprite/LedUncheckedSprite fields across all 19 committed layout fixtures — purely additive, confirmed against the live installed DAT (0x10000520's own 0x82/0x83 properties resolve to 0x06004D17/0x06004D19 exactly as AP-195 documented). Conformance: FilterRows/FilterBlocks pinned against the byte-verified authored order and ChatWindowState's own default constants; the AP-195 LED swap and self-sizing behavior; the DAT opacity-default extraction against the live installed DAT; live filter/opacity writes reaching ChatWindowState/RetailWindowOpacityController; OnShown re-seed and Reset/Defaults ghosting per the OP4 binding-pattern discipline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
4798302226
commit
e71e5a9614
52 changed files with 4540 additions and 40 deletions
92
src/AcDream.App/UI/Layout/ChatOptionsDatDefaults.cs
Normal file
92
src/AcDream.App/UI/Layout/ChatOptionsDatDefaults.cs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
using AcDream.Content;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign OP slice OP5: reads the Chat tab's two opacity-slider defaults from the
|
||||
/// installed DAT's <c>DBProperties</c> collection — retail
|
||||
/// <c>UIOption::InqDefaultGameplayOptionProperty @0x004ef8d0</c>'s
|
||||
/// <c>m_propName</c>-bound path, the ONE genuine consumer of that mechanism (the
|
||||
/// Character tab's 49/50 checkboxes are <c>PlayerOption</c>-bound, not
|
||||
/// <c>m_propName</c>-bound — see <see cref="CharacterOptionsPageController"/>'s own
|
||||
/// U1 trace for why THAT tab does not use this seam).
|
||||
///
|
||||
/// <para>
|
||||
/// Byte-verified recipe (<c>DBCache::GetDIDFromEnum @0x00413940</c>): the outer call
|
||||
/// site is <c>GetDIDFromEnumStatic(&ret, 0x16, 2)</c> — outer arg2 (<c>0x16</c>)
|
||||
/// feeds the inner function's arg3 (the SUB-MAP lookup key), outer arg3 (<c>2</c>)
|
||||
/// feeds arg4 (the MASTER-MAP lookup key FIRST). So:
|
||||
/// <c>master.ClientEnumToID[2] → subMapDid; subMap.ClientEnumToID[0x16] → DID</c> —
|
||||
/// i.e. <see cref="RetailDataIdResolver.Resolve"/>'s own <c>(enumValue, enumCategory)</c>
|
||||
/// parameter order is <c>(0x16, 2)</c>, matching
|
||||
/// <c>RetailUiRuntime.cs</c>'s existing <c>Resolve(dats, 2u, 5u)</c> convention
|
||||
/// (enumCategory selects the master-map submap FIRST, enumValue indexes it SECOND).
|
||||
/// Empirically confirmed against the installed DATs: resolves to DID
|
||||
/// <c>0x78000001</c>, a 3-entry <c>DBProperties</c> —
|
||||
/// <c>0x1000007F</c> (<c>Bitfield64</c>, the Chat filter blocks' shared
|
||||
/// <c>m_propName</c> — NOT a per-window default; the five blocks' actual defaults are
|
||||
/// the literal constants <c>gmChatOptionsUI::InitOptions</c> authors per window,
|
||||
/// ported verbatim in <c>ChatWindowState</c>/<see cref="ChatOptionsPageController"/>),
|
||||
/// <c>0x10000080</c> (<c>Float</c>, <c>0.5</c> — <c>Option_DefaultOpacity_Property</c>),
|
||||
/// <c>0x10000081</c> (<c>Float</c>, <c>1.0</c> — <c>Option_ActiveOpacity_Property</c>).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ChatOptionsDatDefaults
|
||||
{
|
||||
/// <summary>DAT enum-category key for the FIRST (master-map) lookup — see the
|
||||
/// class doc's byte-verified recipe.</summary>
|
||||
private const uint EnumCategory = 2u;
|
||||
|
||||
/// <summary>DAT enum-value key for the SECOND (sub-map) lookup.</summary>
|
||||
private const uint EnumValue = 0x16u;
|
||||
|
||||
/// <summary><c>Option_DefaultOpacity_Property</c> — the unfocused-window opacity
|
||||
/// slider's <c>m_propName</c>.</summary>
|
||||
public const uint DefaultOpacityPropertyId = 0x10000080u;
|
||||
|
||||
/// <summary><c>Option_ActiveOpacity_Property</c> — the focused-window opacity
|
||||
/// slider's <c>m_propName</c>.</summary>
|
||||
public const uint ActiveOpacityPropertyId = 0x10000081u;
|
||||
|
||||
/// <summary>Retail base-constructor fallback (<c>ChatInterface::ChatInterface
|
||||
/// @0x004F4550</c>) — used only when the DAT read fails (missing DAT entry,
|
||||
/// corrupt install); the live installed DAT resolves to these exact values today.</summary>
|
||||
public const float FallbackDefaultOpacity = 0.5f;
|
||||
|
||||
public const float FallbackActiveOpacity = 1.0f;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the two slider defaults from the live DAT. Returns
|
||||
/// <see langword="false"/> (with both out params set to the retail-constructor
|
||||
/// fallback) if the DID does not resolve, the <c>DBProperties</c> object is
|
||||
/// missing, or either property is absent/not a <c>Float</c> — matching this
|
||||
/// codebase's "degrade to a documented fallback, never invent a value" discipline.
|
||||
/// </summary>
|
||||
public static bool TryRead(
|
||||
IDatReaderWriter dats, out float defaultOpacity, out float activeOpacity)
|
||||
{
|
||||
defaultOpacity = FallbackDefaultOpacity;
|
||||
activeOpacity = FallbackActiveOpacity;
|
||||
|
||||
uint did = RetailDataIdResolver.Resolve(dats, EnumValue, EnumCategory);
|
||||
if (did == 0u || !dats.Portal.TryGet<DBProperties>(did, out DBProperties? props) || props is null)
|
||||
return false;
|
||||
|
||||
bool ok = true;
|
||||
if (props.Properties.TryGetValue(DefaultOpacityPropertyId, out BaseProperty? d)
|
||||
&& d is FloatBaseProperty defaultProp)
|
||||
defaultOpacity = defaultProp.Value;
|
||||
else
|
||||
ok = false;
|
||||
|
||||
if (props.Properties.TryGetValue(ActiveOpacityPropertyId, out BaseProperty? a)
|
||||
&& a is FloatBaseProperty activeProp)
|
||||
activeOpacity = activeProp.Value;
|
||||
else
|
||||
ok = false;
|
||||
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
478
src/AcDream.App/UI/Layout/ChatOptionsPageController.cs
Normal file
478
src/AcDream.App/UI/Layout/ChatOptionsPageController.cs
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
using System;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Core.Chat;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign OP slice OP5 (2026-08-11): binds the Chat tab (LayoutDesc
|
||||
/// <c>0x2100005C</c>, root <c>0x1000050A</c>, ListBox <c>0x1000050D</c>) — retail's
|
||||
/// <c>gmChatOptionsUI::InitOptions @0x0049FC60</c> — through OP2's template-list
|
||||
/// mechanism and OP3's per-page <see cref="OptionPage"/> model, exactly like
|
||||
/// <see cref="CharacterOptionsPageController"/>. Six authored sections, byte-verified
|
||||
/// end-to-end against <c>docs/research/2026-08-10-options-panel-structure.md</c> §5
|
||||
/// AND the raw <c>gmChatOptionsUI::InitOptions</c>/<c>AddCheckboxBitfield64Option</c>
|
||||
/// pseudo-C (research doc's own row table was cross-checked against BOTH the inline
|
||||
/// main/floaty4 call sites and the shared helper's <c>switch</c> — every mask, header
|
||||
/// key, separator, and the 12-vs-13-row Gameplay-row gap matched exactly):
|
||||
///
|
||||
/// <list type="number">
|
||||
/// <item><description>General Options — header, then TWO LINKED opacity sliders
|
||||
/// (<c>Option_DefaultOpacity_Property 0x10000080</c> / <c>Option_ActiveOpacity_Property
|
||||
/// 0x10000081</c>), then a separator.</description></item>
|
||||
/// <item><description>Five per-window filter sections (main window id 8, floaties
|
||||
/// 2-5) — header, one <see cref="UiCheckboxBitfield64"/> block, separator. Main gets
|
||||
/// 12 rows (no "Gameplay" row); every floaty gets 13.</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>The one 64-bit register, not 128 bits.</b> Retail's own
|
||||
/// <c>UIOption_CheckboxBitfield64::AddChild(this, uint64_t mask, uint32_t labelId,
|
||||
/// uint32_t tooltipId)</c> signature (byte-verified from the pseudo-C function
|
||||
/// declaration, not just call-site operands) takes exactly ONE 64-bit mask — matching
|
||||
/// <c>ChatWindowState</c>'s own single-<c>ulong</c>-per-window model (its committed
|
||||
/// defaults, e.g. main <c>0xFBFFFFFFu</c>, are byte-identical to the research doc's
|
||||
/// "(high:low)" row/window tables once "high:low" is read as bits[63:32]:bits[31:0] of
|
||||
/// THAT one register — a BN push-immediate artifact for the 64-bit argument, not a
|
||||
/// genuine 128-bit two-register value). <see cref="UiCheckboxBitfield64"/>'s own
|
||||
/// <c>CurrentLow</c>/<c>CurrentHigh</c> pair is exactly that split, so every mask this
|
||||
/// controller feeds it is produced by <see cref="SplitMask"/> from ONE canonical
|
||||
/// <see cref="ulong"/> — never independently derived low/high literals.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Retail window id vs acdream compact id.</b> Retail's <c>SetUserData</c> argument
|
||||
/// (8 main, 2-5 floaties) is the id <c>PlayerModule::SetChatWindowOption</c> dispatches
|
||||
/// on; <see cref="ChatWindowState"/> uses its OWN compact <c>0</c>-<c>4</c> range
|
||||
/// (Campaign CH slice CH6b). <see cref="FilterBlockSpec.CompactWindowId"/> carries the
|
||||
/// mapping so nothing downstream needs to know retail's raw numbering.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ChatOptionsPageController
|
||||
{
|
||||
/// <summary>Chat page root — <c>gmChatOptionsUI</c> — the STANDALONE
|
||||
/// <c>0x2100005C</c> layout's own root id. Only present as its own distinct node
|
||||
/// when <c>0x2100005C</c> is imported directly (e.g.
|
||||
/// <c>FixtureLoader.LoadOptionsChat()</c>); when mounted through the tab host
|
||||
/// (<c>0x2100006E</c>/<c>0x2100002B</c>, what <see cref="Bind"/> actually
|
||||
/// operates on), <c>ElementReader.Merge</c>'s "derived id wins" rule means the
|
||||
/// PAGE SLOT keeps its own id (<see cref="PageSlotElementId"/>) instead — use
|
||||
/// that one for any host-tree lookup.</summary>
|
||||
public const uint RootElementId = 0x1000050Au;
|
||||
|
||||
/// <summary>The Chat page's SLOT element within the tab host
|
||||
/// (<c>OptionsPanelController</c>'s own private <c>ChatPageId</c>) — the id that
|
||||
/// actually survives base-merge in the host-mounted tree <see cref="Bind"/>
|
||||
/// operates on. Used to SCOPE the scrollbar lookup below.</summary>
|
||||
private const uint PageSlotElementId = 0x1000050Cu;
|
||||
|
||||
/// <summary>The row ListBox (dat Type 5) — <c>m_pOptionBox</c>.</summary>
|
||||
public const uint ListBoxElementId = 0x1000050Du;
|
||||
|
||||
/// <summary>The ListBox's linked scrollbar — shared with the Config tab (research
|
||||
/// doc §10.1: both tabs author the SAME scrollbar element id).</summary>
|
||||
public const uint ScrollbarElementId = 0x10000201u;
|
||||
|
||||
private const int HeaderTemplateIndex = 0;
|
||||
private const int SeparatorTemplateIndex = 1;
|
||||
private const int UnlabelledSliderTemplateIndex = 3;
|
||||
private const int LabelledSliderTemplateIndex = 6;
|
||||
private const int BitfieldTemplateIndex = 8;
|
||||
|
||||
private const uint StringTableId = 0x23000003u;
|
||||
|
||||
/// <summary>The slider leaf inside either slider row template's subtree.</summary>
|
||||
private const uint SliderElementId = 0x1000021Cu;
|
||||
|
||||
/// <summary>Labelled slider template's low/high range-caption children
|
||||
/// (research doc §1.5's template array; present only on template idx 6).</summary>
|
||||
private const uint SliderRangeMinElementId = 0x1000021Eu;
|
||||
private const uint SliderRangeMaxElementId = 0x1000021Fu;
|
||||
|
||||
/// <summary>One authored filter row: its combined 64-bit mask (bits[63:32] =
|
||||
/// research doc's "high", bits[31:0] = "low") and retail's own
|
||||
/// <c>ID_ChatOption_TextFilter_*</c> symbol name (used verbatim as the hash key —
|
||||
/// "Allegience" is retail's OWN misspelling in the symbol; the STRING TABLE value
|
||||
/// it resolves to is properly spelled "Allegiance", per structure doc §10.4 item 8
|
||||
/// and §5.2's own footnote).</summary>
|
||||
public readonly record struct FilterRowSpec(ulong Mask, string RetailLabelKey);
|
||||
|
||||
/// <summary>
|
||||
/// The 13-row authored order, byte-verified against BOTH
|
||||
/// <c>AddCheckboxBitfield64Option</c>'s shared tail (Combat..Error, always present)
|
||||
/// and the two inline blocks' own Gameplay-row prefix (main SKIPS it; window 5
|
||||
/// INCLUDES it) — research doc §5.2. Row 0 (Gameplay) is the one
|
||||
/// <see cref="FilterBlockSpec.IncludesGameplayRow"/> gates per block.
|
||||
/// </summary>
|
||||
public static readonly FilterRowSpec[] FilterRows =
|
||||
{
|
||||
new(0x0000000083912021ul, "ID_ChatOption_TextFilter_Gameplay"),
|
||||
new(0x0000000000600040ul, "ID_ChatOption_TextFilter_Combat"),
|
||||
new(0x0000000000020080ul, "ID_ChatOption_TextFilter_Magic"),
|
||||
new(0x0000000000001004ul, "ID_ChatOption_TextFilter_AreaSpeech"),
|
||||
new(0x0000000000000018ul, "ID_ChatOption_TextFilter_Tells"),
|
||||
new(0x0000000000040C00ul, "ID_ChatOption_TextFilter_Allegience"), // retail's own misspelling
|
||||
new(0x0000000000080000ul, "ID_ChatOption_TextFilter_Fellowship"),
|
||||
new(0x0000000008000000ul, "ID_ChatOption_TextFilter_General"),
|
||||
new(0x0000000010000000ul, "ID_ChatOption_TextFilter_Trade"),
|
||||
new(0x0000000020000000ul, "ID_ChatOption_TextFilter_LFG"),
|
||||
new(0x0000000040000000ul, "ID_ChatOption_TextFilter_Roleplay"),
|
||||
new(0x0000000100000000ul, "ID_ChatOption_TextFilter_Society"),
|
||||
new(0x0000000004000000ul, "ID_ChatOption_TextFilter_Error"),
|
||||
};
|
||||
|
||||
/// <summary>One authored per-window filter block/section.</summary>
|
||||
public readonly record struct FilterBlockSpec(
|
||||
int RetailWindowId,
|
||||
int CompactWindowId,
|
||||
string HeaderKey,
|
||||
ulong DefaultFilter,
|
||||
bool IncludesGameplayRow);
|
||||
|
||||
/// <summary>
|
||||
/// The five window sections in authored order — research doc §5/§5.2, byte-
|
||||
/// verified defaults matching <see cref="ChatWindowState"/>'s own committed
|
||||
/// constants exactly (both sides derive from the SAME retail literals; this table
|
||||
/// reuses <see cref="ChatWindowState"/>'s named constants rather than re-declaring
|
||||
/// them, so the two can never drift).
|
||||
/// </summary>
|
||||
public static readonly FilterBlockSpec[] FilterBlocks =
|
||||
{
|
||||
new(8, ChatWindowState.MainWindowId, "ID_ChatOption_MainChatWindow_Section",
|
||||
ChatWindowState.MainWindowDefaultFilter, IncludesGameplayRow: false),
|
||||
new(2, 1, "ID_ChatOption_FloatyChatWindow1_Section",
|
||||
ChatWindowState.Floaty1DefaultFilter, IncludesGameplayRow: true),
|
||||
new(3, 2, "ID_ChatOption_FloatyChatWindow2_Section",
|
||||
ChatWindowState.Floaty2DefaultFilter, IncludesGameplayRow: true),
|
||||
new(4, 3, "ID_ChatOption_FloatyChatWindow3_Section",
|
||||
ChatWindowState.Floaty3DefaultFilter, IncludesGameplayRow: true),
|
||||
new(5, 4, "ID_ChatOption_FloatyChatWindow4_Section",
|
||||
ChatWindowState.Floaty4DefaultFilter, IncludesGameplayRow: true),
|
||||
};
|
||||
|
||||
/// <summary>The live read/write seam every row on this page writes/reads
|
||||
/// through. Opacity routes to <c>RetailWindowOpacityController</c> (AP-190's
|
||||
/// live per-window fade); filters route to the shared <see cref="ChatWindowState"/>
|
||||
/// CH6 already consumes. The two DAT-extracted defaults are resolved ONCE by the
|
||||
/// caller (<c>RetailUiRuntime</c>) — <see cref="ChatOptionsDatDefaults"/> — since
|
||||
/// they never change within a session.</summary>
|
||||
public sealed record Bindings(
|
||||
Func<float> CurrentDefaultOpacity,
|
||||
Func<float> CurrentActiveOpacity,
|
||||
Action<float> SetDefaultOpacity,
|
||||
Action<float> SetActiveOpacity,
|
||||
float DefaultOpacityDatDefault,
|
||||
float ActiveOpacityDatDefault,
|
||||
Func<int, ulong> CurrentFilter,
|
||||
Action<int, ulong> SetFilter);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the General Options header + 2 sliders + 5 filter sections (header +
|
||||
/// block each) into <paramref name="layout"/>'s Chat ListBox, links its
|
||||
/// scrollbar, seeds every row's current/default state, and registers each row
|
||||
/// into <paramref name="page"/>.
|
||||
/// </summary>
|
||||
/// <param name="layout">The already-built Options panel tree — the Chat page's
|
||||
/// ListBox is a descendant of it via the tab host's page-slot base-merge (same
|
||||
/// import <see cref="OptionsPanelController.Bind"/> used).</param>
|
||||
/// <param name="page">The Chat tab's <see cref="OptionPage"/> model
|
||||
/// (<see cref="OptionsPanelController.ChatPage"/>).</param>
|
||||
/// <param name="templateResolver">Builds one row template's subtree — wired onto
|
||||
/// the ListBox's <see cref="UiTemplateListBox.TemplateResolver"/> AND called
|
||||
/// directly for the five bitfield blocks (bypassing
|
||||
/// <see cref="UiTemplateListBox.AddItemFromTemplateList"/>'s own resolve step —
|
||||
/// see <see cref="UiTemplateListBox.AddPrebuiltRow"/> for why).</param>
|
||||
/// <param name="resolveString">Resolves ONE <c>ID_ChatOption_*</c> string by
|
||||
/// (table id, hash) — never hard-coded English.</param>
|
||||
/// <param name="bindings">The live read/write seam for every row.</param>
|
||||
/// <returns><see langword="false"/> if the Chat page's ListBox did not resolve as
|
||||
/// a <see cref="UiTemplateListBox"/>.</returns>
|
||||
public static bool Bind(
|
||||
ImportedLayout layout,
|
||||
OptionPage page,
|
||||
Func<uint, uint, UiElement?> templateResolver,
|
||||
Func<uint, uint, string?> resolveString,
|
||||
Bindings bindings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
ArgumentNullException.ThrowIfNull(page);
|
||||
ArgumentNullException.ThrowIfNull(templateResolver);
|
||||
ArgumentNullException.ThrowIfNull(resolveString);
|
||||
ArgumentNullException.ThrowIfNull(bindings);
|
||||
|
||||
if (layout.FindElement(ListBoxElementId) is not UiTemplateListBox listBox)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[D.2b] ChatOptionsPageController: ListBox 0x{ListBoxElementId:X8} "
|
||||
+ "not found (or not a UiTemplateListBox) in the built Options panel tree — "
|
||||
+ "the Chat tab will have no rows.");
|
||||
return false;
|
||||
}
|
||||
|
||||
listBox.TemplateResolver = templateResolver;
|
||||
|
||||
// ScrollbarElementId (0x10000201) is SHARED with the Config tab (research
|
||||
// doc §10.1 — both tabs author the same scrollbar element id). ImportedLayout's
|
||||
// FindElement is a flat id->widget dictionary (last-build-wins on a
|
||||
// collision), so a plain layout.FindElement(ScrollbarElementId) here can
|
||||
// silently wire THIS scroll model onto Config's own scrollbar instance
|
||||
// instead of Chat's — scope the search to the Chat page's own subtree
|
||||
// instead (same "scoped search for a shared id" shape
|
||||
// OptionsPanelController uses for the Apply/Reset/Defaults buttons). Scoped
|
||||
// from PageSlotElementId, NOT RootElementId — see that field's own doc for
|
||||
// why the standalone layout's root id does not survive base-merge here.
|
||||
UiElement? chatPageSlot = layout.FindElement(PageSlotElementId);
|
||||
UiElement? scrollbarElement = chatPageSlot is null
|
||||
? null
|
||||
: UiElement.FindDescendant(chatPageSlot, ScrollbarElementId);
|
||||
if (scrollbarElement is UiScrollbar scrollbar)
|
||||
scrollbar.Model = listBox.Scroll;
|
||||
else
|
||||
Console.WriteLine(
|
||||
$"[D.2b] ChatOptionsPageController: scrollbar 0x{ScrollbarElementId:X8} "
|
||||
+ $"not found under Chat page slot 0x{PageSlotElementId:X8} — the Chat "
|
||||
+ "tab's row list will not scroll.");
|
||||
|
||||
BuildHeaderRow(listBox, "ID_ChatOption_GeneralOptions_Section", resolveString);
|
||||
BuildOpacitySliders(listBox, page, resolveString, bindings);
|
||||
BuildSeparatorRow(listBox);
|
||||
|
||||
foreach (FilterBlockSpec spec in FilterBlocks)
|
||||
{
|
||||
BuildHeaderRow(listBox, spec.HeaderKey, resolveString);
|
||||
BuildFilterBlock(listBox, spec, page, templateResolver, resolveString, bindings);
|
||||
BuildSeparatorRow(listBox);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void BuildHeaderRow(
|
||||
UiTemplateListBox listBox, string headerKey, Func<uint, uint, string?> resolveString)
|
||||
{
|
||||
if (listBox.AddItemFromTemplateList(HeaderTemplateIndex) is not UiText header)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[D.2b] ChatOptionsPageController: header template did not build as "
|
||||
+ $"UiText for '{headerKey}'.");
|
||||
return;
|
||||
}
|
||||
|
||||
string? label = resolveString(StringTableId, DatStringResolver.ComputeHash(headerKey));
|
||||
if (label is null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[D.2b] ChatOptionsPageController: header string '{headerKey}' did not "
|
||||
+ "resolve from the DAT string table — the row renders with no text rather "
|
||||
+ "than an invented label.");
|
||||
return;
|
||||
}
|
||||
|
||||
header.LinesProvider = () => new[] { new UiText.Line(label, header.DefaultColor) };
|
||||
}
|
||||
|
||||
private static void BuildSeparatorRow(UiTemplateListBox listBox)
|
||||
{
|
||||
if (listBox.AddItemFromTemplateList(SeparatorTemplateIndex) is null)
|
||||
Console.WriteLine("[D.2b] ChatOptionsPageController: separator template did not build.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slider 1 (template idx 3, unlabelled — <c>Option_DefaultOpacity_Property</c>)
|
||||
/// and slider 2 (template idx 6, labelled with the
|
||||
/// <c>ID_UI_Value_Transparent</c>/<c>ID_UI_Value_Opaque</c> range captions —
|
||||
/// <c>Option_ActiveOpacity_Property</c>). Retail's <c>DualHash</c> slider link
|
||||
/// (research doc §5.1) is ported through <see cref="FloatOptionRow.RefreshFromLink"/>:
|
||||
/// dragging one applies through <paramref name="bindings"/> (which itself runs
|
||||
/// <c>ChatOpacityLink</c> and re-applies live), then pushes whatever the OTHER
|
||||
/// value became onto its own row/slider — raising one drags the other, never
|
||||
/// clamps.
|
||||
/// </summary>
|
||||
private static void BuildOpacitySliders(
|
||||
UiTemplateListBox listBox,
|
||||
OptionPage page,
|
||||
Func<uint, uint, string?> resolveString,
|
||||
Bindings bindings)
|
||||
{
|
||||
UiElement? row1 = listBox.AddItemFromTemplateList(UnlabelledSliderTemplateIndex);
|
||||
UiScrollbar? slider1 = row1 is null ? null : UiElement.FindDescendant(row1, SliderElementId) as UiScrollbar;
|
||||
if (slider1 is null)
|
||||
Console.WriteLine(
|
||||
"[D.2b] ChatOptionsPageController: default-opacity slider template did not "
|
||||
+ "build a UiScrollbar leaf.");
|
||||
|
||||
UiElement? row2 = listBox.AddItemFromTemplateList(LabelledSliderTemplateIndex);
|
||||
UiScrollbar? slider2 = row2 is null ? null : UiElement.FindDescendant(row2, SliderElementId) as UiScrollbar;
|
||||
if (slider2 is null)
|
||||
Console.WriteLine(
|
||||
"[D.2b] ChatOptionsPageController: active-opacity slider template did not "
|
||||
+ "build a UiScrollbar leaf.");
|
||||
|
||||
if (row2 is not null)
|
||||
{
|
||||
SetRangeLabel(row2, SliderRangeMinElementId, "ID_UI_Value_Transparent", resolveString);
|
||||
SetRangeLabel(row2, SliderRangeMaxElementId, "ID_UI_Value_Opaque", resolveString);
|
||||
}
|
||||
|
||||
if (slider1 is null || slider2 is null)
|
||||
return;
|
||||
|
||||
float initialDefault = bindings.CurrentDefaultOpacity();
|
||||
float initialActive = bindings.CurrentActiveOpacity();
|
||||
slider1.SetScalarPosition(initialDefault);
|
||||
slider2.SetScalarPosition(initialActive);
|
||||
|
||||
FloatOptionRow? defaultRow = null;
|
||||
FloatOptionRow? activeRow = null;
|
||||
|
||||
defaultRow = new FloatOptionRow(
|
||||
initialDefault,
|
||||
bindings.DefaultOpacityDatDefault,
|
||||
apply: value =>
|
||||
{
|
||||
bindings.SetDefaultOpacity(value);
|
||||
activeRow!.RefreshFromLink(bindings.CurrentActiveOpacity());
|
||||
},
|
||||
read: bindings.CurrentDefaultOpacity,
|
||||
refresh: value => slider1.SetScalarPosition(value));
|
||||
|
||||
activeRow = new FloatOptionRow(
|
||||
initialActive,
|
||||
bindings.ActiveOpacityDatDefault,
|
||||
apply: value =>
|
||||
{
|
||||
bindings.SetActiveOpacity(value);
|
||||
defaultRow!.RefreshFromLink(bindings.CurrentDefaultOpacity());
|
||||
},
|
||||
read: bindings.CurrentActiveOpacity,
|
||||
refresh: value => slider2.SetScalarPosition(value));
|
||||
|
||||
slider1.ScalarChanged = value => defaultRow.SetCurrentValue(value);
|
||||
slider2.ScalarChanged = value => activeRow.SetCurrentValue(value);
|
||||
|
||||
page.Register(defaultRow);
|
||||
page.Register(activeRow);
|
||||
}
|
||||
|
||||
private static void SetRangeLabel(
|
||||
UiElement row, uint elementId, string labelKey, Func<uint, uint, string?> resolveString)
|
||||
{
|
||||
if (UiElement.FindDescendant(row, elementId) is not UiText text)
|
||||
return;
|
||||
string? label = resolveString(StringTableId, DatStringResolver.ComputeHash(labelKey));
|
||||
if (label is null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[D.2b] ChatOptionsPageController: range label '{labelKey}' did not "
|
||||
+ "resolve — rendered with no text rather than invented English.");
|
||||
return;
|
||||
}
|
||||
text.LinesProvider = () => new[] { new UiText.Line(label, text.DefaultColor) };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One per-window filter block: resolves the block template DIRECTLY through
|
||||
/// <paramref name="templateResolver"/> (NOT via
|
||||
/// <see cref="UiTemplateListBox.AddItemFromTemplateList"/> — the block must be
|
||||
/// fully populated, growing to its final self-sized height (AP-195), BEFORE it is
|
||||
/// stacked into <paramref name="listBox"/>'s viewport), seeds it with retail's
|
||||
/// authored window default, adds every authored row (skipping "Gameplay" on the
|
||||
/// main window per <see cref="FilterBlockSpec.IncludesGameplayRow"/>), THEN inserts
|
||||
/// it via <see cref="UiTemplateListBox.AddPrebuiltRow"/>.
|
||||
/// </summary>
|
||||
private static void BuildFilterBlock(
|
||||
UiTemplateListBox listBox,
|
||||
FilterBlockSpec spec,
|
||||
OptionPage page,
|
||||
Func<uint, uint, UiElement?> templateResolver,
|
||||
Func<uint, uint, string?> resolveString,
|
||||
Bindings bindings)
|
||||
{
|
||||
if (BitfieldTemplateIndex >= listBox.Templates.Count)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[D.2b] ChatOptionsPageController: bitfield template index "
|
||||
+ $"{BitfieldTemplateIndex} missing from the Chat ListBox's authored "
|
||||
+ $"template list — window {spec.RetailWindowId}'s filter block was not built.");
|
||||
return;
|
||||
}
|
||||
|
||||
UiTemplateListEntry entry = listBox.Templates[BitfieldTemplateIndex];
|
||||
UiElement? built = templateResolver(entry.TemplateLayoutId, entry.TemplateElementId);
|
||||
if (built is not UiCheckboxBitfield64 block)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[D.2b] ChatOptionsPageController: filter block template did not build as "
|
||||
+ $"UiCheckboxBitfield64 for window {spec.RetailWindowId}.");
|
||||
return;
|
||||
}
|
||||
|
||||
// The block resolved fresh through templateResolver above — it needs its
|
||||
// OWN TemplateResolver wired (a SEPARATE property from the outer
|
||||
// listBox.TemplateResolver) before AddChild can resolve its per-row
|
||||
// template (0x10000521), exactly like the outer ListBox's own wiring a
|
||||
// few lines up in Bind().
|
||||
block.TemplateResolver = templateResolver;
|
||||
|
||||
(ulong defaultLow, ulong defaultHigh) = SplitMask(spec.DefaultFilter);
|
||||
block.SetDefaultValue(defaultLow, defaultHigh);
|
||||
|
||||
for (int i = 0; i < FilterRows.Length; i++)
|
||||
{
|
||||
if (i == 0 && !spec.IncludesGameplayRow)
|
||||
continue; // research doc §5.2: the main window has no Gameplay row.
|
||||
|
||||
FilterRowSpec rowSpec = FilterRows[i];
|
||||
string? label = resolveString(StringTableId, DatStringResolver.ComputeHash(rowSpec.RetailLabelKey));
|
||||
string? tooltip = resolveString(
|
||||
StringTableId, DatStringResolver.ComputeHash(rowSpec.RetailLabelKey + "_Desc"));
|
||||
if (label is null)
|
||||
Console.WriteLine(
|
||||
$"[D.2b] ChatOptionsPageController: filter row label "
|
||||
+ $"'{rowSpec.RetailLabelKey}' did not resolve — row renders with no "
|
||||
+ "caption rather than invented English.");
|
||||
|
||||
(ulong low, ulong high) = SplitMask(rowSpec.Mask);
|
||||
if (block.AddChild(low, high, label ?? string.Empty, tooltip) is null)
|
||||
Console.WriteLine(
|
||||
$"[D.2b] ChatOptionsPageController: filter row '{rowSpec.RetailLabelKey}' "
|
||||
+ $"did not build for window {spec.RetailWindowId}.");
|
||||
}
|
||||
|
||||
listBox.AddPrebuiltRow(block);
|
||||
|
||||
ulong initial = bindings.CurrentFilter(spec.CompactWindowId);
|
||||
(ulong initialLow, ulong initialHigh) = SplitMask(initial);
|
||||
block.SetCurrentValue(initialLow, initialHigh); // live seed, not the window default
|
||||
|
||||
var row = new BitfieldOptionRow(
|
||||
initial,
|
||||
spec.DefaultFilter,
|
||||
apply: value =>
|
||||
{
|
||||
(ulong low, ulong high) = SplitMask(value);
|
||||
block.SetCurrentValue(low, high);
|
||||
bindings.SetFilter(spec.CompactWindowId, value);
|
||||
},
|
||||
read: () => bindings.CurrentFilter(spec.CompactWindowId),
|
||||
refresh: value =>
|
||||
{
|
||||
(ulong low, ulong high) = SplitMask(value);
|
||||
block.SetCurrentValue(low, high);
|
||||
});
|
||||
|
||||
block.ValueChanged = (low, high) => row.SetCurrentValue(CombineMask(low, high));
|
||||
|
||||
page.Register(row);
|
||||
}
|
||||
|
||||
/// <summary>Splits ONE 64-bit filter value into (bits[31:0], bits[63:32]) —
|
||||
/// the same split <see cref="UiCheckboxBitfield64"/>'s Low/High pair encodes (see
|
||||
/// this class's own doc for why that is not a genuine 128-bit value).</summary>
|
||||
private static (ulong Low, ulong High) SplitMask(ulong combined)
|
||||
=> (combined & 0xFFFFFFFFul, (combined >> 32) & 0xFFFFFFFFul);
|
||||
|
||||
/// <summary>Inverse of <see cref="SplitMask"/>.</summary>
|
||||
private static ulong CombineMask(ulong low, ulong high)
|
||||
=> ((high & 0xFFFFFFFFul) << 32) | (low & 0xFFFFFFFFul);
|
||||
}
|
||||
|
|
@ -159,7 +159,11 @@ public static class DatWidgetFactory
|
|||
// gmChatOptionsUI::AddCheckboxBitfield64Option call pattern (research doc
|
||||
// §5.2). AddChild(lowMask, highMask, label, tooltip) resolves that SAME
|
||||
// template per row instead of synthesizing a fake ElementInfo.
|
||||
0x10000044u => new UiCheckboxBitfield64(info.TemplateList),
|
||||
// AP-195 (OP5): the block ALSO authors its own all/partial-set LED media
|
||||
// (dat properties 0x10000082/0x10000083) directly on this element — thread
|
||||
// them through so Refresh's ported LED swap has real sprites to apply.
|
||||
0x10000044u => new UiCheckboxBitfield64(
|
||||
info.TemplateList, info.LedCheckedSprite, info.LedUncheckedSprite),
|
||||
_ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -198,6 +198,25 @@ public sealed class ElementInfo
|
|||
/// </summary>
|
||||
public List<UiTemplateListEntry> TemplateList = new();
|
||||
|
||||
/// <summary>
|
||||
/// AP-195 (Campaign OP slice OP5): <c>UIOption_CheckboxBitfield64</c>'s block-level
|
||||
/// "all mask bits set" LED media (dat property <c>0x10000082</c>,
|
||||
/// <c>P0x10000082 = 0x06004D17</c> on the authored template consumer
|
||||
/// <c>0x10000520</c>) — retail <c>Refresh @0x004859C0</c> swaps a row's LED to THIS
|
||||
/// sprite when every bit of that row's mask is currently set. 0 when absent.
|
||||
/// Populated the same way as <see cref="TabTable"/>.
|
||||
/// </summary>
|
||||
public uint LedCheckedSprite;
|
||||
|
||||
/// <summary>
|
||||
/// AP-195 counterpart of <see cref="LedCheckedSprite"/>: the "some but not all mask
|
||||
/// bits set" LED media (dat property <c>0x10000083</c>,
|
||||
/// <c>P0x10000083 = 0x06004D19</c>). Retail applies this ONLY while the row is
|
||||
/// checked (any-set) but not fully checked (all-set) — see
|
||||
/// <c>UiCheckboxBitfield64</c>'s own doc for the full any/all-set decode.
|
||||
/// </summary>
|
||||
public uint LedUncheckedSprite;
|
||||
|
||||
/// <summary>
|
||||
/// Element id of this ListBox's linked scrollbar, read from dat property
|
||||
/// <c>0x72</c> (e.g. the Character page's ListBox <c>0x100001FA</c> names
|
||||
|
|
@ -503,6 +522,13 @@ public static class ElementReader
|
|||
// (same doc §10.1 — e.g. Character ListBox 0x100001FA names scrollbar
|
||||
// 0x100001FB).
|
||||
info.ScrollbarElementId = ReadReferencedElementId(info, 0x72u);
|
||||
|
||||
// AP-195: the CheckboxBitfield64 block's own all/partial-set LED media DIDs
|
||||
// (0x10000082/0x10000083 — DataId-kind properties, verified against the
|
||||
// committed options_2100002B.json fixture at element 0x10000520: Kind=2
|
||||
// (DataId), UnsignedValue 100683031/100683033 == 0x06004D17/0x06004D19).
|
||||
info.LedCheckedSprite = ReadReferencedElementId(info, 0x10000082u);
|
||||
info.LedUncheckedSprite = ReadReferencedElementId(info, 0x10000083u);
|
||||
}
|
||||
|
||||
private static List<UiTabTableEntry> ReadTabTable(ElementInfo info)
|
||||
|
|
|
|||
|
|
@ -168,6 +168,198 @@ public sealed class BoolOptionRow : IOptionRow
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign OP slice OP5: the Chat tab's two linked opacity sliders' leaf —
|
||||
/// <c>UIOption_Slider</c>'s current/saved/default triple over a <see cref="float"/>,
|
||||
/// same shape as <see cref="BoolOptionRow"/> (research doc §3.3's leaf semantics apply
|
||||
/// identically; retail's <c>UIOption_Slider</c> shares the same base <c>UIOption</c>
|
||||
/// verbs as <c>UIOption_Checkbox</c>, just over a float value). <see cref="SetCurrentValue"/>
|
||||
/// is what dragging the slider thumb runs — retail's <c>SetCurrentValue → Apply(1)</c>
|
||||
/// applies LIVE, immediately, on every drag tick (not just mouse-up), which is what
|
||||
/// makes the two Chat-tab sliders visibly fade windows WHILE dragging rather than only
|
||||
/// on release.
|
||||
/// </summary>
|
||||
public sealed class FloatOptionRow : IOptionRow
|
||||
{
|
||||
private readonly Action<float>? _apply;
|
||||
private readonly Func<float>? _read;
|
||||
private readonly Action<float>? _refresh;
|
||||
private Action? _notifyPageOptionChanged;
|
||||
private float _current;
|
||||
private float _saved;
|
||||
private float _default;
|
||||
|
||||
/// <param name="read">Re-reads the LIVE value on <see cref="SaveCurrentValue"/>
|
||||
/// (OnShown/Apply) — same MUST-FIX 1 discipline as <see cref="BoolOptionRow"/>'s
|
||||
/// own <c>read</c> parameter.</param>
|
||||
/// <param name="refresh">Pushes a re-read/linked value onto the slider widget
|
||||
/// WITHOUT invoking <paramref name="apply"/> — used by both the OnShown re-seed
|
||||
/// AND by <see cref="RefreshFromLink"/> (the linked slider's own drag moving THIS
|
||||
/// row's value as a side effect, per <c>ChatOpacityLink</c>'s never-clamp-always-
|
||||
/// drag-the-other invariant).</param>
|
||||
public FloatOptionRow(
|
||||
float initial,
|
||||
float defaultValue,
|
||||
Action<float>? apply = null,
|
||||
Func<float>? read = null,
|
||||
Action<float>? refresh = null)
|
||||
{
|
||||
_current = initial;
|
||||
_saved = initial;
|
||||
_default = defaultValue;
|
||||
_apply = apply;
|
||||
_read = read;
|
||||
_refresh = refresh;
|
||||
}
|
||||
|
||||
/// <summary>The live value — what the slider thumb currently shows.</summary>
|
||||
public float Current => _current;
|
||||
|
||||
/// <summary>The committed baseline Reset reverts to.</summary>
|
||||
public float Saved => _saved;
|
||||
|
||||
/// <summary>The value Defaults restores (the DAT <c>DBProperties</c>-extracted
|
||||
/// slider default for the Chat tab's two sliders — U1's
|
||||
/// <c>InqDefaultGameplayOptionProperty</c> mechanism, this tab's genuine
|
||||
/// consumer).</summary>
|
||||
public float DefaultValue => _default;
|
||||
|
||||
public bool Changed => _saved != _current;
|
||||
|
||||
public void SetDefaultValue(float value) => _default = value;
|
||||
|
||||
/// <summary>Retail <c>SetCurrentValue</c> — the drag-tick entry point. Writes
|
||||
/// <c>m_current</c> and applies it live IMMEDIATELY; does not touch
|
||||
/// <see cref="Saved"/>.</summary>
|
||||
public void SetCurrentValue(float value)
|
||||
{
|
||||
_current = value;
|
||||
_apply?.Invoke(value);
|
||||
_notifyPageOptionChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The LINKED slider's own drag already ran <c>ChatOpacityLink</c>'s math and
|
||||
/// applied the new pair live (a single <c>RetailWindowOpacityController</c> call
|
||||
/// covers both values) — this pushes the resulting value onto THIS row/widget
|
||||
/// without re-applying (that would double-fire the live opacity write) but still
|
||||
/// notifies the page, since the OTHER slider's <c>Changed</c> baseline may now
|
||||
/// differ too (retail's <c>DualHash</c> link moves both sliders' own
|
||||
/// <c>m_current</c>).
|
||||
/// </summary>
|
||||
public void RefreshFromLink(float value)
|
||||
{
|
||||
_current = value;
|
||||
_refresh?.Invoke(value);
|
||||
_notifyPageOptionChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void AttachPageNotify(Action notify) => _notifyPageOptionChanged = notify;
|
||||
|
||||
public void SaveCurrentValue()
|
||||
{
|
||||
if (_read is not null)
|
||||
{
|
||||
_current = _read();
|
||||
_refresh?.Invoke(_current);
|
||||
}
|
||||
_saved = _current;
|
||||
}
|
||||
|
||||
public void RestoreSavedValue()
|
||||
{
|
||||
_current = _saved;
|
||||
_apply?.Invoke(_current);
|
||||
}
|
||||
|
||||
public void RestoreDefaultValue()
|
||||
{
|
||||
_current = _default;
|
||||
_apply?.Invoke(_current);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign OP slice OP5: one of the Chat tab's five per-window text-filter
|
||||
/// <c>UIOption_CheckboxBitfield64</c> blocks — the current/saved/default triple over
|
||||
/// the block's combined 64-bit filter value (retail's genuine one-register
|
||||
/// <c>m_llTextTypeFilter</c> shape; see <c>UiCheckboxBitfield64</c>'s own class doc for
|
||||
/// why the widget's <c>CurrentLow</c>/<c>CurrentHigh</c> pair is just that ONE 64-bit
|
||||
/// value split at the 32-bit boundary, not a 128-bit value). Unlike
|
||||
/// <see cref="BoolOptionRow"/>/<see cref="FloatOptionRow"/>, the WIDGET already IS the
|
||||
/// live UI for N individual checkboxes and fires <see cref="UiCheckboxBitfield64.ValueChanged"/>
|
||||
/// on any row toggle — this row wraps that composite value the same way a page needs,
|
||||
/// translating widget-originated edits into <see cref="AttachPageNotify"/>/
|
||||
/// <c>bindings.SetFilter</c> and page-originated reverts (Reset/Defaults/OnShown) into
|
||||
/// <see cref="UiCheckboxBitfield64.SetCurrentValue"/> pushes.
|
||||
/// </summary>
|
||||
public sealed class BitfieldOptionRow : IOptionRow
|
||||
{
|
||||
private readonly Action<ulong>? _apply;
|
||||
private readonly Func<ulong>? _read;
|
||||
private readonly Action<ulong>? _refresh;
|
||||
private Action? _notifyPageOptionChanged;
|
||||
private ulong _current;
|
||||
private ulong _saved;
|
||||
private ulong _default;
|
||||
|
||||
public BitfieldOptionRow(
|
||||
ulong initial,
|
||||
ulong defaultValue,
|
||||
Action<ulong>? apply = null,
|
||||
Func<ulong>? read = null,
|
||||
Action<ulong>? refresh = null)
|
||||
{
|
||||
_current = initial;
|
||||
_saved = initial;
|
||||
_default = defaultValue;
|
||||
_apply = apply;
|
||||
_read = read;
|
||||
_refresh = refresh;
|
||||
}
|
||||
|
||||
public ulong Current => _current;
|
||||
public ulong Saved => _saved;
|
||||
public ulong DefaultValue => _default;
|
||||
public bool Changed => _saved != _current;
|
||||
|
||||
public void SetDefaultValue(ulong value) => _default = value;
|
||||
|
||||
/// <summary>The widget-originated entry point — wired to
|
||||
/// <see cref="UiCheckboxBitfield64.ValueChanged"/>, fired the instant a row
|
||||
/// checkbox toggles (retail's own per-checkbox <c>Apply(1)</c>).</summary>
|
||||
public void SetCurrentValue(ulong value)
|
||||
{
|
||||
_current = value;
|
||||
_apply?.Invoke(value);
|
||||
_notifyPageOptionChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void AttachPageNotify(Action notify) => _notifyPageOptionChanged = notify;
|
||||
|
||||
public void SaveCurrentValue()
|
||||
{
|
||||
if (_read is not null)
|
||||
{
|
||||
_current = _read();
|
||||
_refresh?.Invoke(_current);
|
||||
}
|
||||
_saved = _current;
|
||||
}
|
||||
|
||||
public void RestoreSavedValue()
|
||||
{
|
||||
_current = _saved;
|
||||
_apply?.Invoke(_current);
|
||||
}
|
||||
|
||||
public void RestoreDefaultValue()
|
||||
{
|
||||
_current = _default;
|
||||
_apply?.Invoke(_current);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>OptionPage</c>/<c>PlayerOptionPage</c>: a page's registered-option
|
||||
/// array plus the four verbs (Apply/Reset/Defaults/visibility) with retail's
|
||||
|
|
|
|||
|
|
@ -657,19 +657,18 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
};
|
||||
|
||||
/// <summary>
|
||||
/// Capture the four floating windows' current text-type filters into
|
||||
/// the local settings store (research doc §4.4/§6.1 — local-only until
|
||||
/// CH6f's wire format). No-op when no store was wired.
|
||||
/// Capture the main window's and four floating windows' current text-type
|
||||
/// filters into the local settings store (research doc §4.4/§6.1 — local-only
|
||||
/// until a future retail <c>0x1000008C</c> wire format). No-op when no store was
|
||||
/// wired.
|
||||
///
|
||||
/// <para>
|
||||
/// CH6a/b REJECT-review NIT 1: unlike window geometry/visibility (which
|
||||
/// <see cref="_persistence"/> auto-saves on every change), a filter
|
||||
/// change is only captured here — i.e. only when <see cref="SaveLayout"/>
|
||||
/// runs, which today is exclusively the explicit <c>/saveautoui</c>
|
||||
/// command. Harmless in practice (nothing currently mutates a window's
|
||||
/// filter live — no options UI exists yet), but worth tightening to
|
||||
/// auto-save-on-change once CH6e/CH6f gives filters a live settings
|
||||
/// surface a user can actually edit mid-session.
|
||||
/// CH6a/b REJECT-review NIT 1 — CLOSED by Campaign OP slice OP5: filters are now
|
||||
/// ALSO captured on every live edit through the Chat tab's five filter blocks
|
||||
/// (each block's <c>BitfieldOptionRow.apply</c> closure calls this directly), not
|
||||
/// only when <see cref="SaveLayout"/> runs via the explicit <c>/saveautoui</c>
|
||||
/// command — OP5 is the "live settings surface a user can actually edit
|
||||
/// mid-session" this NIT was waiting on.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void SaveChatWindowFilters()
|
||||
|
|
@ -679,6 +678,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
ChatSettings current = store.LoadChat();
|
||||
store.SaveChat(current with
|
||||
{
|
||||
ChatWindowMainFilter = windows.GetFilter(ChatWindowState.MainWindowId),
|
||||
ChatWindow1Filter = windows.GetFilter(1),
|
||||
ChatWindow2Filter = windows.GetFilter(2),
|
||||
ChatWindow3Filter = windows.GetFilter(3),
|
||||
|
|
@ -686,6 +686,25 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign OP slice OP5: persists the Chat tab's two opacity-slider values —
|
||||
/// the "worth tightening to auto-save-on-change" gap
|
||||
/// <see cref="SaveChatWindowFilters"/>'s own doc flagged before a live options UI
|
||||
/// existed to edit them. Called from the Chat tab's slider apply closures, so
|
||||
/// every drag tick is captured (matching the filter blocks' own auto-save-on-
|
||||
/// change wiring below, not the old "only on /saveautoui" schedule).
|
||||
/// </summary>
|
||||
private void SaveChatOpacity()
|
||||
{
|
||||
if (_bindings.Chat.Store is not { } store) return;
|
||||
ChatSettings current = store.LoadChat();
|
||||
store.SaveChat(current with
|
||||
{
|
||||
DefaultOpacity = WindowOpacity.DefaultOpacity,
|
||||
ActiveOpacity = WindowOpacity.ActiveOpacity,
|
||||
});
|
||||
}
|
||||
|
||||
public void CloseWindow(string name)
|
||||
{
|
||||
if (RetailPanelCatalog.TryGetPanelId(name, out uint panelId))
|
||||
|
|
@ -898,6 +917,16 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
return;
|
||||
}
|
||||
|
||||
// Campaign OP slice OP5: the main window's own filter (id 0) is now
|
||||
// user-settable through the Chat tab's main-window filter block
|
||||
// (research doc §5.2) — seed it from the local store here, the same
|
||||
// "local-only persistence" leg MountFloatingChatWindows already runs
|
||||
// for windows 1-4 (research doc §4.4/§6.1 — the retail 0x1000008C
|
||||
// gameplay-options wire remains explicitly deferred).
|
||||
if (_bindings.Chat.Store is { } chatStore)
|
||||
_bindings.Chat.Windows.SetFilter(
|
||||
ChatWindowState.MainWindowId, chatStore.LoadChat().ChatWindowMainFilter);
|
||||
|
||||
ChatWindowController? controller = ChatWindowController.Bind(
|
||||
info,
|
||||
layout,
|
||||
|
|
@ -2040,6 +2069,68 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
Console.WriteLine("[UI] options panel: Character tab rows did not bind.");
|
||||
}
|
||||
|
||||
// Campaign OP slice OP5 (2026-08-11): the Chat tab's 6 headers, 2 linked
|
||||
// opacity sliders, and 5 per-window filter blocks. Same "runs before
|
||||
// ActivateTabs, own dat-lock scope" shape as the Character-tab block above.
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
var strings = new DatStringResolver(_bindings.Assets.Dats);
|
||||
|
||||
// U1's Chat-tab-specific consumer (this class's own doc trace): the
|
||||
// two sliders' Defaults values come from the DAT DBProperties
|
||||
// collection, resolved ONCE — the installed DAT does not change
|
||||
// within a session.
|
||||
if (!Layout.ChatOptionsDatDefaults.TryRead(
|
||||
_bindings.Assets.Dats, out float datDefaultOpacity, out float datActiveOpacity))
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[UI] options panel: Chat tab opacity DAT defaults did not resolve "
|
||||
+ "(DID 0x78000001) — falling back to retail's ChatInterface base-"
|
||||
+ "constructor values (0.5/1.0).");
|
||||
}
|
||||
|
||||
bool chatBound = Layout.ChatOptionsPageController.Bind(
|
||||
layout,
|
||||
controller.ChatPage,
|
||||
templateResolver: (templateLayoutId, templateElementId) =>
|
||||
{
|
||||
ElementInfo? info = LayoutImporter.ImportInfos(
|
||||
_bindings.Assets.Dats, templateLayoutId, templateElementId);
|
||||
return info is null
|
||||
? null
|
||||
: LayoutImporter.Build(
|
||||
info,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont,
|
||||
strings.Resolve).Root;
|
||||
},
|
||||
resolveString: (tableId, stringId) => strings.Resolve(tableId, stringId),
|
||||
new Layout.ChatOptionsPageController.Bindings(
|
||||
CurrentDefaultOpacity: () => WindowOpacity.DefaultOpacity,
|
||||
CurrentActiveOpacity: () => WindowOpacity.ActiveOpacity,
|
||||
SetDefaultOpacity: value =>
|
||||
{
|
||||
WindowOpacity.SetDefaultOpacity(value);
|
||||
SaveChatOpacity();
|
||||
},
|
||||
SetActiveOpacity: value =>
|
||||
{
|
||||
WindowOpacity.SetActiveOpacity(value);
|
||||
SaveChatOpacity();
|
||||
},
|
||||
DefaultOpacityDatDefault: datDefaultOpacity,
|
||||
ActiveOpacityDatDefault: datActiveOpacity,
|
||||
CurrentFilter: windowId => _bindings.Chat.Windows.GetFilter(windowId),
|
||||
SetFilter: (windowId, value) =>
|
||||
{
|
||||
_bindings.Chat.Windows.SetFilter(windowId, value);
|
||||
SaveChatWindowFilters();
|
||||
}));
|
||||
if (!chatBound)
|
||||
Console.WriteLine("[UI] options panel: Chat tab rows did not bind.");
|
||||
}
|
||||
|
||||
controller.ActivateTabs();
|
||||
|
||||
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||
|
|
|
|||
|
|
@ -116,6 +116,24 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
public float FaceWidth { get; set; }
|
||||
public float FaceHeight { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// AP-195 (Campaign OP slice OP5): when non-null, drawn as the face media INSTEAD
|
||||
/// of <see cref="ActiveState"/>'s resolved sprite — a dat RenderSurface id, not a
|
||||
/// pre-resolved texture handle (resolved through the same <c>_resolve</c> callback
|
||||
/// as the normal path, so tiling/UV math stays identical). Ports retail
|
||||
/// <c>UIOption_CheckboxBitfield64::Refresh @0x004859C0</c>'s
|
||||
/// <c>SetMediaImageForState(indicatorChild, dataId, 1, 6)</c>: the Chat tab's
|
||||
/// per-window filter rows force their 5-state LED indicator to ONE fixed image
|
||||
/// (the block-level all/partial-set sprite) regardless of hover/press state,
|
||||
/// bypassing the checkbox's own per-state media entirely. Null (default) restores
|
||||
/// the ordinary <see cref="ActiveState"/>-driven lookup — every other button
|
||||
/// instance in the codebase is unaffected. Same shape as
|
||||
/// <see cref="Layout.UiDatElement.RuntimeImageTexture"/>, deliberately a DID here
|
||||
/// (not a texture handle) because the checkbox draw path already resolves its
|
||||
/// file id through <c>_resolve</c> for correct native-size UV tiling.
|
||||
/// </summary>
|
||||
public uint? FaceFileOverride { get; set; }
|
||||
|
||||
/// <summary>Additional left inset for left-aligned labels.</summary>
|
||||
public float LabelOffsetX { get; set; } = 3f;
|
||||
|
||||
|
|
@ -326,7 +344,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
}
|
||||
else
|
||||
{
|
||||
uint file = ActiveFile(_mediaInfo);
|
||||
uint file = FaceFileOverride ?? ActiveFile(_mediaInfo);
|
||||
if (file != 0)
|
||||
{
|
||||
var (tex, tw, th) = _resolve(file);
|
||||
|
|
|
|||
|
|
@ -70,6 +70,19 @@ public sealed class UiCheckboxBitfield64 : UiPanel
|
|||
/// </summary>
|
||||
public IReadOnlyList<UiTemplateListEntry> Templates { get; }
|
||||
|
||||
/// <summary>
|
||||
/// AP-195 (Campaign OP slice OP5): block-level "all mask bits set" LED media
|
||||
/// (dat property <c>0x10000082</c> on THIS element, <c>0x06004D17</c> on the
|
||||
/// installed DAT). 0 when the authoring template carries none — the LED override
|
||||
/// is then simply never applied (rows keep their per-row authored art).
|
||||
/// </summary>
|
||||
public uint CheckedLedSprite { get; }
|
||||
|
||||
/// <summary>AP-195 counterpart of <see cref="CheckedLedSprite"/>: block-level
|
||||
/// "checked but not every mask bit set" LED media (dat property
|
||||
/// <c>0x10000083</c>, <c>0x06004D19</c>).</summary>
|
||||
public uint UncheckedLedSprite { get; }
|
||||
|
||||
/// <summary>Same seam as <see cref="UiTemplateListBox.TemplateResolver"/> — a page
|
||||
/// controller wires this with real DAT access (Campaign OP slice OP5+). Left null
|
||||
/// by <c>DatWidgetFactory</c> itself.</summary>
|
||||
|
|
@ -84,12 +97,21 @@ public sealed class UiCheckboxBitfield64 : UiPanel
|
|||
// SpriteResolve (forwarded to each row's built subtree) is inherited from UiPanel —
|
||||
// same resolver shape, no need to redeclare it.
|
||||
|
||||
/// <summary>Fired with the new (low, high) value after any row toggles.</summary>
|
||||
/// <summary>Fired with the new (low, high) value after any row toggles — a LIVE
|
||||
/// user edit only (retail's <c>Apply(1)</c> path). NEVER fired by
|
||||
/// <see cref="SetCurrentValue"/> (the OnShown/Reset/Defaults re-seed path — pushing
|
||||
/// an externally-sourced value must not loop back into "the user changed this").
|
||||
/// </summary>
|
||||
public Action<ulong, ulong>? ValueChanged { get; set; }
|
||||
|
||||
public UiCheckboxBitfield64(IReadOnlyList<UiTemplateListEntry> templates)
|
||||
public UiCheckboxBitfield64(
|
||||
IReadOnlyList<UiTemplateListEntry> templates,
|
||||
uint checkedLedSprite = 0u,
|
||||
uint uncheckedLedSprite = 0u)
|
||||
{
|
||||
Templates = templates;
|
||||
CheckedLedSprite = checkedLedSprite;
|
||||
UncheckedLedSprite = uncheckedLedSprite;
|
||||
BackgroundColor = Vector4.Zero;
|
||||
BorderColor = Vector4.Zero;
|
||||
}
|
||||
|
|
@ -121,6 +143,24 @@ public sealed class UiCheckboxBitfield64 : UiPanel
|
|||
ValueChanged?.Invoke(CurrentLow, CurrentHigh);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushes an externally-sourced (low, high) value onto the widget — the page-model
|
||||
/// re-seed path (retail's <c>Refresh</c> re-run after
|
||||
/// <c>OptionPage::RestoreSavedValues</c>/<c>RestoreDefaultValues</c>/the OnShown
|
||||
/// <c>SaveCurrentValue</c> re-read). Updates <see cref="CurrentLow"/>/
|
||||
/// <see cref="CurrentHigh"/> and every row's checked state + AP-195 LED media, but
|
||||
/// deliberately does NOT invoke <see cref="ValueChanged"/> — the caller (an
|
||||
/// <c>IOptionRow</c> wrapper) already owns whatever notification it needs, and
|
||||
/// firing here would loop a re-seed back into "the user changed this" (the same
|
||||
/// live/refresh distinction <c>BoolOptionRow</c>'s <c>refresh</c> delegate makes).
|
||||
/// </summary>
|
||||
public void SetCurrentValue(ulong low, ulong high)
|
||||
{
|
||||
CurrentLow = low;
|
||||
CurrentHigh = high;
|
||||
RefreshRowVisuals();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>UIOption_CheckboxBitfield64::AddChild(lowMask, highMask, labelId,
|
||||
/// tooltipId)</c> → <c>CreateChildren</c>'s per-row loop: instantiates the authored
|
||||
|
|
@ -174,7 +214,6 @@ public sealed class UiCheckboxBitfield64 : UiPanel
|
|||
// replaced by the typed mask closure below: equivalent routing,
|
||||
// without a stringly attribute round-trip.
|
||||
checkbox.TooltipText = tooltip;
|
||||
checkbox.Selected = IsSet(lowMask, highMask);
|
||||
checkbox.OnClick = () => ToggleRow(lowMask, highMask, checkbox);
|
||||
|
||||
row.Left = 0f;
|
||||
|
|
@ -182,7 +221,24 @@ public sealed class UiCheckboxBitfield64 : UiPanel
|
|||
_contentHeight += row.Height;
|
||||
base.AddChild(row);
|
||||
|
||||
_rows.Add(new Row(lowMask, highMask, label, tooltip, row, checkbox));
|
||||
// AP-195 self-sizing tail: retail's own CreateChildren ends with
|
||||
// ResizeTo(GetWidth(), CalculatePaperSize(0, -1)) because
|
||||
// UIOption_CheckboxBitfield64 IS a UIElement_ListBox (its PostInit
|
||||
// tail-calls UIElement_Scrollable::PostInit) — the block grows to fit its
|
||||
// rows instead of keeping its authored 272x100 extent. Width is untouched
|
||||
// (GetWidth() — the ListBox's own authored column width); only Height
|
||||
// grows, exactly matching the stacked _contentHeight this class already
|
||||
// tracks per row. A dedicated ListBox-unification pass (reusing
|
||||
// UiTemplateListBox's own viewport) remains future work per AP-195's own
|
||||
// disposition menu; this direct resize is the recorded-rationale half of
|
||||
// that menu — see UiTemplateListBox.AddPrebuiltRow for how a page
|
||||
// controller reflows the ENCLOSING ListBox around this widget's now-final
|
||||
// height instead of the pre-build authored one.
|
||||
Height = _contentHeight;
|
||||
|
||||
var newRow = new Row(lowMask, highMask, label, tooltip, row, checkbox);
|
||||
_rows.Add(newRow);
|
||||
ApplyRowVisuals(newRow);
|
||||
return checkbox;
|
||||
}
|
||||
|
||||
|
|
@ -199,18 +255,26 @@ public sealed class UiCheckboxBitfield64 : UiPanel
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>UIOption_CheckboxBitfield64::Refresh @0x004859C0</c>: a row is checked
|
||||
/// when ANY bit of its mask is set in the current value — NOT when every bit is
|
||||
/// set. <c>lowHit = current.low & mask.low; highHit = current.high &
|
||||
/// mask.high; checked = (lowHit | highHit) != 0</c>. This is HALF of Refresh:
|
||||
/// the same pass also computes the ALL-set predicate to swap each row's LED
|
||||
/// media between the two authored surfaces (<c>P0x10000082 = 0x06004D17</c> /
|
||||
/// <c>P0x10000083 = 0x06004D19</c>) — not ported yet, register row AP-195,
|
||||
/// due with the Chat tab (OP5) alongside the ListBox self-sizing tail.
|
||||
/// Retail <c>UIOption_CheckboxBitfield64::Refresh @0x004859C0</c>'s ANY-set half:
|
||||
/// a row is checked when ANY bit of its mask is set in the current value — NOT
|
||||
/// when every bit is set. <c>lowHit = current.low & mask.low; highHit =
|
||||
/// current.high & mask.high; checked = (lowHit | highHit) != 0</c>.
|
||||
/// </summary>
|
||||
private bool IsSet(ulong lowMask, ulong highMask)
|
||||
private bool IsAnySet(ulong lowMask, ulong highMask)
|
||||
=> (CurrentLow & lowMask) != 0 || (CurrentHigh & highMask) != 0;
|
||||
|
||||
/// <summary>
|
||||
/// AP-195: retail <c>Refresh</c>'s ALL-set half, computed by the SAME pass as
|
||||
/// <see cref="IsAnySet"/> — <c>(current.low & mask.low) == mask.low &&
|
||||
/// (current.high & mask.high) == mask.high</c>. Drives which of
|
||||
/// <see cref="CheckedLedSprite"/>/<see cref="UncheckedLedSprite"/> a checked row's
|
||||
/// LED shows: composite masks (e.g. the Gameplay row's <c>0x83912021</c>, five
|
||||
/// bits) can be PARTIALLY satisfied — checked (any-set) but not fully (all-set) —
|
||||
/// which single-bit rows (e.g. "General") can never observably distinguish.
|
||||
/// </summary>
|
||||
private bool IsAllSet(ulong lowMask, ulong highMask)
|
||||
=> (CurrentLow & lowMask) == lowMask && (CurrentHigh & highMask) == highMask;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0</c>:
|
||||
/// <c>BitUtils::SetBitsOnOrOff(&m_current, mask, onOff)</c> — decomp-confirmed
|
||||
|
|
@ -219,7 +283,7 @@ public sealed class UiCheckboxBitfield64 : UiPanel
|
|||
/// </summary>
|
||||
private void ToggleRow(ulong lowMask, ulong highMask, UiButton toggle)
|
||||
{
|
||||
bool turnOn = !IsSet(lowMask, highMask);
|
||||
bool turnOn = !IsAnySet(lowMask, highMask);
|
||||
if (turnOn)
|
||||
{
|
||||
CurrentLow |= lowMask;
|
||||
|
|
@ -230,13 +294,37 @@ public sealed class UiCheckboxBitfield64 : UiPanel
|
|||
CurrentLow &= ~lowMask;
|
||||
CurrentHigh &= ~highMask;
|
||||
}
|
||||
toggle.Selected = turnOn;
|
||||
ApplyRowVisualsForMask(lowMask, highMask, toggle);
|
||||
ValueChanged?.Invoke(CurrentLow, CurrentHigh);
|
||||
}
|
||||
|
||||
private void RefreshRowVisuals()
|
||||
{
|
||||
foreach (Row row in _rows)
|
||||
row.Toggle.Selected = IsSet(row.LowMask, row.HighMask);
|
||||
ApplyRowVisuals(row);
|
||||
}
|
||||
|
||||
private void ApplyRowVisuals(Row row) => ApplyRowVisualsForMask(row.LowMask, row.HighMask, row.Toggle);
|
||||
|
||||
/// <summary>
|
||||
/// One row's complete <c>Refresh</c> visual: the ANY-set checked bool
|
||||
/// (<see cref="UiButton.Selected"/>, always) plus AP-195's ALL-set-driven LED
|
||||
/// media swap (<see cref="UiButton.FaceFileOverride"/>) — retail's own gate: the
|
||||
/// swap ONLY applies while ANY-set is true; a fully-unchecked row keeps its
|
||||
/// authored per-row art untouched (<c>Refresh @0x004859C0</c>'s
|
||||
/// <c>if (ebx == 0) { ... media swap ... }</c> branch, where <c>ebx == 0</c> is
|
||||
/// the ANY-set case per the byte trace in this class's own research citations).
|
||||
/// </summary>
|
||||
private void ApplyRowVisualsForMask(ulong lowMask, ulong highMask, UiButton toggle)
|
||||
{
|
||||
bool anySet = IsAnySet(lowMask, highMask);
|
||||
toggle.Selected = anySet;
|
||||
if (!anySet)
|
||||
{
|
||||
toggle.FaceFileOverride = null;
|
||||
return;
|
||||
}
|
||||
uint overrideSprite = IsAllSet(lowMask, highMask) ? CheckedLedSprite : UncheckedLedSprite;
|
||||
toggle.FaceFileOverride = overrideSprite != 0u ? overrideSprite : null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -179,6 +179,35 @@ public sealed class UiTemplateListBox : UiDatElement
|
|||
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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue