Fixes the OP5 (Chat tab) dual-lens review findings againste71e5a96: - M1 (MUST-FIX): each opacity row's own apply closure now pushes its OWN slider's thumb from the post-link truth (bindings.Current*Opacity()), mirroring the OP4 binding pattern. Before this, a single-slider drag followed by Reset reverted the live value/link but left that slider's own thumb stuck at the dragged position. - S1 (SHOULD-FIX): the Chat tab's two opacity sliders no longer round-trip the whole settings.json on every drag MouseMove tick. UiScrollbar gains IsDragging + a DragCompleted callback (fires once, at the MouseUp that ends an actual thumb drag); the opacity apply closures flush immediately when not mid-drag (Reset/Defaults/discrete edits, same as before) and defer to DragCompleted otherwise, collapsing dozens of per-tick writes into exactly one per drag gesture. Live opacity still applies every tick. - S2 (SHOULD-FIX): filed register row AP-201 and issue #371 for the UiScrollablePanel whole-row-cull-vs-clip divergence the review found (predates OP5, made user-visible by OP5's 240-260px filter blocks). Not fixed in this round (a renderer-level scissor stack is out of scope here) — corrected the OP5 connected-gate script instead so a straddling block's disappear-then-reappear-whole is no longer reported as a self-sizing regression. - S3 (SHOULD-FIX): the chatWindowMainFilter round-trip test already existed ine71e5a96(the review missed it scrolling past line 330); added the genuinely missing coverage instead — a composed test pinning RetailUiRuntime.MountChat's window-0 SettingsStore -> ChatWindowState seed (MountChat itself needs live DAT access and isn't unit-testable directly). - N11: ScrollbarLinkage_ModelPointsAtTheChatListBoxScroll now asserts through the scoped page-slot lookup (UiElement.FindDescendant) instead of the flat layout.FindElement, which passed for the wrong reason given the shared scrollbar id 0x10000201 — matches OP6's own scrollbar-linkage test pattern. Also updated ConfigOptionsPageControllerTests' local ChatOptionsPageController Bindings fake for the new FlushOpacity parameter. Full Release suite: 13,117 passed / 4 skipped / 0 failed (baseline 13,107/4/0 post-OP6 — 10 tests added, zero skips added, zero failures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
516 lines
26 KiB
C#
516 lines
26 KiB
C#
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.
|
|
/// <para>
|
|
/// OP5 review fix S1 (2026-08-11): <see cref="SetDefaultOpacity"/>/
|
|
/// <see cref="SetActiveOpacity"/> apply the value LIVE only — they must never
|
|
/// perform the settings.json round trip themselves, or a slider drag (dozens of
|
|
/// <c>MouseMove</c> ticks) becomes dozens of whole-file load+rewrite cycles on the
|
|
/// UI thread. <see cref="FlushOpacity"/> is the separate settle seam this
|
|
/// controller calls exactly once per discrete edit (a Reset/Defaults click, or a
|
|
/// drag's own <c>MouseUp</c> via <see cref="UiScrollbar.DragCompleted"/>) — never
|
|
/// once per tick — matching retail's own dirty-timer batching (structure doc
|
|
/// §3.5) for this blob.
|
|
/// </para></summary>
|
|
public sealed record Bindings(
|
|
Func<float> CurrentDefaultOpacity,
|
|
Func<float> CurrentActiveOpacity,
|
|
Action<float> SetDefaultOpacity,
|
|
Action<float> SetActiveOpacity,
|
|
Action FlushOpacity,
|
|
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.
|
|
/// <para>
|
|
/// OP5 review fix M1 (2026-08-11): each row's own <c>apply</c> closure pushes its
|
|
/// OWN slider's thumb from the post-link truth (<c>bindings.Current*Opacity()</c>,
|
|
/// not the raw dragged value — the link may have clamped it), mirroring the OP4
|
|
/// binding pattern (a sibling widget's own edit pushes its own widget first). Without
|
|
/// this, <see cref="FloatOptionRow.RestoreSavedValue"/>/<c>RestoreDefaultValue</c>
|
|
/// (Reset/Defaults on ONLY this row) call <c>apply</c> but never
|
|
/// <c>refresh</c>, leaving the thumb visually stuck at the pre-revert position.
|
|
/// </para>
|
|
/// <para>
|
|
/// OP5 review fix S1 (2026-08-11): each row's own <c>apply</c> closure flushes the
|
|
/// batched settings write (<see cref="Bindings.FlushOpacity"/>) immediately UNLESS
|
|
/// its own slider is mid-drag (<see cref="UiScrollbar.IsDragging"/>) — a Reset/
|
|
/// Defaults click is never mid-drag, so those keep writing immediately (single
|
|
/// discrete edit, same as before); a drag tick IS mid-drag, so the write defers to
|
|
/// the matching <see cref="UiScrollbar.DragCompleted"/> callback wired below,
|
|
/// collapsing dozens of <c>MouseMove</c>-tick writes into exactly one per gesture.
|
|
/// </para>
|
|
/// </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);
|
|
slider1.SetScalarPosition(bindings.CurrentDefaultOpacity()); // M1: own slider first
|
|
activeRow!.RefreshFromLink(bindings.CurrentActiveOpacity());
|
|
if (!slider1.IsDragging) bindings.FlushOpacity(); // S1: settle now unless mid-drag
|
|
},
|
|
read: bindings.CurrentDefaultOpacity,
|
|
refresh: value => slider1.SetScalarPosition(value));
|
|
|
|
activeRow = new FloatOptionRow(
|
|
initialActive,
|
|
bindings.ActiveOpacityDatDefault,
|
|
apply: value =>
|
|
{
|
|
bindings.SetActiveOpacity(value);
|
|
slider2.SetScalarPosition(bindings.CurrentActiveOpacity()); // M1: own slider first
|
|
defaultRow!.RefreshFromLink(bindings.CurrentDefaultOpacity());
|
|
if (!slider2.IsDragging) bindings.FlushOpacity(); // S1: settle now unless mid-drag
|
|
},
|
|
read: bindings.CurrentActiveOpacity,
|
|
refresh: value => slider2.SetScalarPosition(value));
|
|
|
|
slider1.ScalarChanged = value => defaultRow.SetCurrentValue(value);
|
|
slider2.ScalarChanged = value => activeRow.SetCurrentValue(value);
|
|
|
|
// S1: the drag-end seam — flushes whatever the tick loop above deferred.
|
|
slider1.DragCompleted = bindings.FlushOpacity;
|
|
slider2.DragCompleted = bindings.FlushOpacity;
|
|
|
|
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);
|
|
}
|