feat(ui): Campaign OP slice OP3 — Options panel shell, open paths, Gameplay tab
Mounts retail's Options panel (LayoutDesc 0x2100002B resolved through host 0x2100006E slot 0x1000018D, gmPanelUI key 10) via the same catalog-import pattern CharacterController already validates, registered through RetailPanelUiController so it shares retail's "one active gmPanelUI child" mutual exclusion with every other sibling panel for free. F11 and the toolbar's options button (0x1000019B, already authoring panel id 10) both now open it; the close button fires the same ToggleOptionsPanel action. OptionPageModel (OptionPage/BoolOptionRow) ports retail's exact Apply/Reset/Defaults/visibility semantics from UIOption_Checkbox/PlayerOptionPage — LED clicks apply live immediately, Apply commits every row unconditionally + flushes the batched blob, Reset reverts only Changed rows, Defaults restores without committing, and tab-switch/window-hide revert uncommitted edits. Wired for all four tabs; this slice registers real rows on none of them (Gameplay authentically has none — a pure button list). UiTabPanel gains an ActivePageChanged event so the page model can hook every tab transition, including the initial default-tab activation. The seven Gameplay-tab buttons: Exit Game reuses the existing graceful window-close path; Exit to Character Selection gets retail's confirmation dialog and byte-verified mid-air refusal but still behaves as Exit Game (AD-74 — no pre-world character-select flow exists); Configure Keyboard and In-Game Help Files are inert this slice (AD-76 for Help — the plugin retail depends on doesn't exist); Urgent Assistance/Report Abuse short-circuit to their own byte-verified failure text through the interface-text seam instead of ShellExecute against a dead URL (AD-75); Use Mouse Turning Settings runs the pure MouseTurningSettingsMacro port, persisting five new CameraTurningSettings preferences and sending PlayerOption.UseMouseTurning — TS-74 records that acdream has no persistent mouse-turning camera mode for the bit to drive yet. Full Release suite: 12,918 passed / 4 skipped / 0 failed (baseline 12,871/4/0 — only new tests added). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
5242de9f15
commit
9d26ecc623
27 changed files with 25696 additions and 8 deletions
106
src/AcDream.App/UI/Layout/MouseTurningSettingsMacro.cs
Normal file
106
src/AcDream.App/UI/Layout/MouseTurningSettingsMacro.cs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Pure port of <c>gmConfigUI::SetMouseTurningDefaults @0x0049E8F0</c> — the
|
||||
/// Gameplay tab's "Use Mouse Turning Settings" button
|
||||
/// (<c>docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md</c> §4.4).
|
||||
/// A one-shot "apply the recommended mouse-turning values" macro over six
|
||||
/// options: five CLIENT-LOCAL preferences
|
||||
/// (<see cref="CameraTurningSettings"/>) plus one server-synced bit
|
||||
/// (<c>PlayerOption.UseMouseTurning</c>, NOT modeled here — the caller
|
||||
/// threads the current/target value in and reads
|
||||
/// <see cref="Result.UseMouseTurningChanged"/> back out to decide whether to
|
||||
/// send <c>SetSingleCharacterOption (0x0005)</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// Byte-verified per-value conditions (research doc §4.4, the MSVC
|
||||
/// <c>fcomp</c>/<c>fnstsw</c>/<c>jnp</c> idiom — the body runs when the
|
||||
/// CURRENT value differs from the macro's target, exact float equality, no
|
||||
/// epsilon): Stiffness → 0.95 iff <c>≠ 0.95</c>; AdjustmentSpeed → 50.0 iff
|
||||
/// <c>≠ 50.0</c>; MouseLookSensitivity → 0.7 iff <c>≠ 0.7</c>; AlignToSlope
|
||||
/// → off iff currently on; InvertMouseLookYAxis → on iff currently off;
|
||||
/// UseMouseTurning → on iff currently off. Retail's directional bool
|
||||
/// conditions (<c>== 1</c> / <c>== 0</c>) are behaviourally identical to a
|
||||
/// plain inequality against the fixed target for a two-state bool, so this
|
||||
/// port uses <c>!=</c> uniformly.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Retail emits one retail-verbatim chat line PER CHANGED value (never for
|
||||
/// an already-at-target value) and finishes with
|
||||
/// <c>SaveCurrentValues()</c> (the caller's job — the macro's target page is
|
||||
/// the Config tab's own <see cref="OptionPage"/>, which this class does not
|
||||
/// know about).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class MouseTurningSettingsMacro
|
||||
{
|
||||
public readonly record struct Result(
|
||||
CameraTurningSettings Updated,
|
||||
bool UseMouseTurningChanged,
|
||||
IReadOnlyList<string> ChatLines);
|
||||
|
||||
/// <summary>
|
||||
/// Computes the macro's outcome. Pure — no I/O, no side effects; the
|
||||
/// caller applies <see cref="Result.Updated"/> (persist + live apply),
|
||||
/// sends the wire bit if <see cref="Result.UseMouseTurningChanged"/>,
|
||||
/// and emits <see cref="Result.ChatLines"/> through the interface-text
|
||||
/// seam, in that order (retail's own call order at
|
||||
/// <c>0x0049E924</c>-<c>0x0049EB57</c>).
|
||||
/// </summary>
|
||||
public static Result Compute(CameraTurningSettings current, bool useMouseTurningCurrent)
|
||||
{
|
||||
CameraTurningSettings target = CameraTurningSettings.MouseTurningTarget;
|
||||
var lines = new List<string>();
|
||||
|
||||
float stiffness = current.Stiffness;
|
||||
if (stiffness != target.Stiffness)
|
||||
{
|
||||
lines.Add(OptionsPanelText.CameraStiffnessChanged(stiffness, target.Stiffness));
|
||||
stiffness = target.Stiffness;
|
||||
}
|
||||
|
||||
float adjustmentSpeed = current.AdjustmentSpeed;
|
||||
if (adjustmentSpeed != target.AdjustmentSpeed)
|
||||
{
|
||||
lines.Add(OptionsPanelText.CameraAdjustmentChanged(adjustmentSpeed, target.AdjustmentSpeed));
|
||||
adjustmentSpeed = target.AdjustmentSpeed;
|
||||
}
|
||||
|
||||
float sensitivity = current.MouseLookSensitivity;
|
||||
if (sensitivity != target.MouseLookSensitivity)
|
||||
{
|
||||
lines.Add(OptionsPanelText.MouseSensitivityChanged(sensitivity, target.MouseLookSensitivity));
|
||||
sensitivity = target.MouseLookSensitivity;
|
||||
}
|
||||
|
||||
bool alignToSlope = current.AlignToSlope;
|
||||
if (alignToSlope != target.AlignToSlope)
|
||||
{
|
||||
lines.Add(OptionsPanelText.AlignToSlopeChanged);
|
||||
alignToSlope = target.AlignToSlope;
|
||||
}
|
||||
|
||||
bool invertY = current.InvertMouseLookYAxis;
|
||||
if (invertY != target.InvertMouseLookYAxis)
|
||||
{
|
||||
lines.Add(OptionsPanelText.InvertMouseLookAxesChanged);
|
||||
invertY = target.InvertMouseLookYAxis;
|
||||
}
|
||||
|
||||
// UseMouseTurning's target is always ON (true) — retail's
|
||||
// `== 0` condition ("currently off").
|
||||
bool useMouseTurningChanged = useMouseTurningCurrent != true;
|
||||
if (useMouseTurningChanged)
|
||||
lines.Add(OptionsPanelText.TurnToFaceCameraChanged);
|
||||
|
||||
return new Result(
|
||||
new CameraTurningSettings(stiffness, adjustmentSpeed, sensitivity, alignToSlope, invertY),
|
||||
useMouseTurningChanged,
|
||||
lines);
|
||||
}
|
||||
}
|
||||
214
src/AcDream.App/UI/Layout/OptionPageModel.cs
Normal file
214
src/AcDream.App/UI/Layout/OptionPageModel.cs
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// One retail <c>OptionPage</c>/<c>PlayerOptionPage</c> row: a
|
||||
/// <c>(m_current, m_saved, m_default)</c> triple plus the three verbs a row
|
||||
/// leaf implements (<c>UIOption_Checkbox</c> is the canonical case — Campaign
|
||||
/// OP slices OP4-6 add slider/menu/bitfield leaves behind the same shape).
|
||||
/// Retail anchors: <c>docs/research/2026-08-10-options-panel-structure.md</c>
|
||||
/// §3.3 (<c>UIOption_Checkbox::Changed @0x004868C0</c>,
|
||||
/// <c>SaveCurrentValue @0x004868E0</c>, <c>RestoreSavedValue @0x00486900</c>,
|
||||
/// <c>RestoreDefaultValue @0x00486930</c>, <c>SetCurrentValue @0x00486970</c>).
|
||||
/// </summary>
|
||||
public interface IOptionRow
|
||||
{
|
||||
/// <summary><c>UIOption_Checkbox::Changed</c>: <c>m_saved != m_current</c>.</summary>
|
||||
bool Changed { get; }
|
||||
|
||||
/// <summary><c>SaveCurrentValue</c>: <c>m_saved = m_current</c>. No live
|
||||
/// side effect — the value is already live (every mutator below applies
|
||||
/// immediately).</summary>
|
||||
void SaveCurrentValue();
|
||||
|
||||
/// <summary><c>RestoreSavedValue</c>: <c>m_current = m_saved</c>, then
|
||||
/// applies the reverted value live.</summary>
|
||||
void RestoreSavedValue();
|
||||
|
||||
/// <summary><c>RestoreDefaultValue</c>: <c>m_current = m_default</c>,
|
||||
/// then applies the default live.</summary>
|
||||
void RestoreDefaultValue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The canonical retail leaf — <c>UIOption_Checkbox</c>'s current/saved/default
|
||||
/// triple over a <see cref="bool"/>. <see cref="SetCurrentValue"/> is what a
|
||||
/// user's LED click runs: it writes <c>m_current</c> and applies it live
|
||||
/// IMMEDIATELY (retail's <c>SetCurrentValue @0x00486970</c> calls
|
||||
/// <c>Apply(1)</c> synchronously) — Apply/Reset/Defaults never gate this; they
|
||||
/// only move the <c>m_saved</c>/<c>m_default</c> baselines and re-apply.
|
||||
/// </summary>
|
||||
public sealed class BoolOptionRow : IOptionRow
|
||||
{
|
||||
private readonly Action<bool>? _apply;
|
||||
private bool _current;
|
||||
private bool _saved;
|
||||
private bool _default;
|
||||
|
||||
public BoolOptionRow(bool initial, bool defaultValue, Action<bool>? apply = null)
|
||||
{
|
||||
_current = initial;
|
||||
_saved = initial;
|
||||
_default = defaultValue;
|
||||
_apply = apply;
|
||||
}
|
||||
|
||||
/// <summary>The live value — what the LED currently shows.</summary>
|
||||
public bool Current => _current;
|
||||
|
||||
/// <summary>The committed baseline Reset reverts to.</summary>
|
||||
public bool Saved => _saved;
|
||||
|
||||
/// <summary>The value Defaults restores. Mutable via
|
||||
/// <see cref="SetDefaultValue"/> — retail's <c>SetDefaultValue</c> is
|
||||
/// authored per-row in each page's <c>InitOptions</c>, sometimes from a
|
||||
/// DAT-resolved default rather than a compile-time literal (OP4's
|
||||
/// Character-tab U1 closure).</summary>
|
||||
public bool DefaultValue => _default;
|
||||
|
||||
public bool Changed => _saved != _current;
|
||||
|
||||
/// <summary>Retail <c>UIOption_Checkbox::SetDefaultValue @0x00486960</c>.</summary>
|
||||
public void SetDefaultValue(bool value) => _default = value;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>SetCurrentValue @0x00486970</c> — the LED-click entry point.
|
||||
/// Writes <c>m_current</c> and applies it live immediately; does NOT
|
||||
/// touch <see cref="Saved"/> (Apply is the only verb that commits).
|
||||
/// </summary>
|
||||
public void SetCurrentValue(bool value)
|
||||
{
|
||||
_current = value;
|
||||
_apply?.Invoke(value);
|
||||
}
|
||||
|
||||
public void SaveCurrentValue() => _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
|
||||
/// EXACT semantics — <c>docs/research/2026-08-10-options-panel-structure.md</c>
|
||||
/// §3 and §10.4:
|
||||
///
|
||||
/// <list type="number">
|
||||
/// <item><description>Clicking an LED applies immediately
|
||||
/// (<see cref="BoolOptionRow.SetCurrentValue"/> → <c>Apply(1)</c>). Apply and
|
||||
/// Reset operate on an undo baseline, not a staging buffer.</description></item>
|
||||
/// <item><description><see cref="Apply"/> commits EVERY row's baseline
|
||||
/// unconditionally (<c>OptionPage::SaveCurrentValues @0x004F2C60</c> — no
|
||||
/// <c>Changed</c> gate), then flushes the batched character-options blob via
|
||||
/// <see cref="AfterApply"/> (<c>PlayerOptionPage::SaveCurrentValues
|
||||
/// @0x004F2710</c>'s <c>CPlayerModule::SaveToServer(0)</c> tailcall).</description></item>
|
||||
/// <item><description><see cref="Reset"/> reverts only rows whose
|
||||
/// <see cref="IOptionRow.Changed"/> is true
|
||||
/// (<c>OptionPage::RestoreSavedValues @0x004F2D00</c>).</description></item>
|
||||
/// <item><description><see cref="Defaults"/> restores every row unconditionally,
|
||||
/// live, WITHOUT committing — <see cref="IOptionRow.Changed"/> can go true
|
||||
/// afterward, re-enabling Apply/Reset
|
||||
/// (<c>OptionPage::RestoreDefaultValues @0x004F2CB0</c>).</description></item>
|
||||
/// <item><description><see cref="OnHidden"/> (page becomes invisible — a tab
|
||||
/// switch away, or the window closing) reverts uncommitted edits exactly like
|
||||
/// Reset (<c>PlayerOptionPage::OnVisibilityChanged(false) @0x004F26E0</c> →
|
||||
/// <c>RestoreSavedValues</c>).</description></item>
|
||||
/// <item><description><see cref="OnShown"/> (page becomes visible — the
|
||||
/// initial default tab, a tab switch in, or the window (re)opening) applies +
|
||||
/// commits exactly like Apply (<c>OnVisibilityChanged(true)</c> →
|
||||
/// <c>SaveCurrentValues</c>, which ALSO flushes the blob via
|
||||
/// <see cref="AfterApply"/>).</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// An empty page (zero registered rows — the Gameplay tab's own model, which
|
||||
/// has no <c>UIOption</c> rows at all per research doc §6) makes every verb a
|
||||
/// no-op and <see cref="Changed"/> permanently false; <see cref="AfterApply"/>
|
||||
/// still fires on <see cref="Apply"/>/<see cref="OnShown"/> (retail's
|
||||
/// <c>SaveCurrentValues</c> flushes the blob regardless of whether THIS
|
||||
/// page's own rows changed anything — the module's dirty flag is global, not
|
||||
/// per-page).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class OptionPage
|
||||
{
|
||||
private readonly List<IOptionRow> _rows = new();
|
||||
|
||||
public IReadOnlyList<IOptionRow> Rows => _rows;
|
||||
|
||||
/// <summary>
|
||||
/// Invoked after every <see cref="Apply"/> (button click OR
|
||||
/// <see cref="OnShown"/>) — the seam a controller wires to the batched
|
||||
/// <c>SaveOptions</c>/blob-flush command. Never invoked by
|
||||
/// <see cref="Reset"/> or <see cref="Defaults"/> (retail's Reset/Defaults
|
||||
/// call <c>Apply(0)</c> per-row and re-run <c>OnOptionChanged(0)</c>
|
||||
/// directly — they never reach <c>PlayerOptionPage::SaveCurrentValues</c>,
|
||||
/// so they never flush).
|
||||
/// </summary>
|
||||
public Action? AfterApply { get; set; }
|
||||
|
||||
/// <summary>Registers one row. Retail's <c>OptionPage::RegisterOption
|
||||
/// @0x004F2E90</c>, called from each page's <c>InitOptions</c>.</summary>
|
||||
public void Register(IOptionRow row)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
_rows.Add(row);
|
||||
}
|
||||
|
||||
/// <summary><c>OptionPage::Changed @0x004F2D60</c>: true if ANY
|
||||
/// registered row's own <see cref="IOptionRow.Changed"/> is true.</summary>
|
||||
public bool Changed => _rows.Any(static row => row.Changed);
|
||||
|
||||
/// <summary><c>OptionPage::SaveCurrentValues @0x004F2C60</c> — Apply:
|
||||
/// commits every row's baseline unconditionally, then flushes via
|
||||
/// <see cref="AfterApply"/>.</summary>
|
||||
public void Apply()
|
||||
{
|
||||
foreach (IOptionRow row in _rows)
|
||||
row.SaveCurrentValue();
|
||||
AfterApply?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary><c>OptionPage::RestoreSavedValues @0x004F2D00</c> — Reset:
|
||||
/// reverts only the rows that are currently <see cref="IOptionRow.Changed"/>.
|
||||
/// Snapshotted before iterating so a row's own revert (which flips
|
||||
/// <see cref="IOptionRow.Changed"/> back to false) cannot skip a later
|
||||
/// row.</summary>
|
||||
public void Reset()
|
||||
{
|
||||
foreach (IOptionRow row in _rows.Where(static row => row.Changed).ToArray())
|
||||
row.RestoreSavedValue();
|
||||
}
|
||||
|
||||
/// <summary><c>OptionPage::RestoreDefaultValues @0x004F2CB0</c> —
|
||||
/// Defaults: restores every row unconditionally, live, without
|
||||
/// committing.</summary>
|
||||
public void Defaults()
|
||||
{
|
||||
foreach (IOptionRow row in _rows)
|
||||
row.RestoreDefaultValue();
|
||||
}
|
||||
|
||||
/// <summary><c>PlayerOptionPage::OnVisibilityChanged(true)</c> — the page
|
||||
/// became visible (initial default tab, a tab switch in, or the window
|
||||
/// (re)opening): applies + commits, same as <see cref="Apply"/>.</summary>
|
||||
public void OnShown() => Apply();
|
||||
|
||||
/// <summary><c>PlayerOptionPage::OnVisibilityChanged(false)</c> — the page
|
||||
/// became hidden (a tab switch away, or the window closing): reverts
|
||||
/// uncommitted edits, same as <see cref="Reset"/>.</summary>
|
||||
public void OnHidden() => Reset();
|
||||
}
|
||||
235
src/AcDream.App/UI/Layout/OptionsPanelController.cs
Normal file
235
src/AcDream.App/UI/Layout/OptionsPanelController.cs
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
using AcDream.Core.Chat;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Mounts retail's Options panel — LayoutDesc <c>0x2100002B</c> (the tab host,
|
||||
/// class <c>gmFloatyPanelUI</c>'s slot content) resolved through host
|
||||
/// <c>0x2100006E</c> at slot <c>0x1000018D</c> (stack key
|
||||
/// <see cref="RetailPanelCatalog.Options"/> = 10) — the SAME catalog-import
|
||||
/// mechanism <see cref="CharacterController"/> already uses for its own
|
||||
/// <c>0x2100006E</c> slot (<c>0x10000183</c>), byte-verified empirically: a
|
||||
/// throwaway probe against the live installed DATs confirmed
|
||||
/// <c>LayoutImporter.ImportInfos(dats, 0x2100006Eu, 0x1000018Du)</c> resolves
|
||||
/// directly to the fully base-merged tab-host content (buttons, page slots,
|
||||
/// each page's own children) at the slot's authored 300×362 extent — no
|
||||
/// separate import of the OTHER ~15 sibling <c>gmPanelUI</c> panels sharing
|
||||
/// that host is needed. <c>RetailPanelUiController</c>
|
||||
/// (<c>RetailUiRuntime.MountOptionsPanel</c>'s <c>RegisterMainPanel</c> call)
|
||||
/// is what gives Options the SAME retail
|
||||
/// "one active <c>gmPanelUI</c> child, opening one hides the others" mutual
|
||||
/// exclusion every sibling panel (Character Info, Vitae, Inventory, ...)
|
||||
/// already has — this controller owns only the panel's OWN content: tab
|
||||
/// activation, the per-tab <see cref="OptionPage"/> model, the close button,
|
||||
/// and the seven Gameplay-tab buttons.
|
||||
///
|
||||
/// <para>
|
||||
/// Research anchors: <c>docs/research/2026-08-10-options-panel-structure.md</c>
|
||||
/// §1.3 (tab table), §1.4 (host/slot), §3.6 (visibility semantics), §10.1
|
||||
/// (structural inventory); <c>docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md</c>
|
||||
/// §1-4 (the seven buttons, byte-verified anchors).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class OptionsPanelController : IRetainedPanelController
|
||||
{
|
||||
/// <summary>The floating host LayoutDesc the tab panel is resolved through.</summary>
|
||||
public const uint HostLayoutId = 0x2100006Eu;
|
||||
|
||||
/// <summary>The Options panel's slot within <see cref="HostLayoutId"/>'s
|
||||
/// shared <c>gmPanelUI</c> page stack — also its
|
||||
/// <see cref="RetailPanelCatalog.Options"/> panel id's element identity.</summary>
|
||||
public const uint SlotElementId = 0x1000018Du;
|
||||
|
||||
// Tab page slot ids (research doc §10.1 — the four page slots mounted
|
||||
// inside the tab host 0x10000208, each already base-merged with its own
|
||||
// page LayoutDesc content by the time this element resolves).
|
||||
private const uint GameplayPageId = 0x10000212u;
|
||||
private const uint CharacterPageId = 0x10000211u;
|
||||
private const uint ChatPageId = 0x1000050Cu;
|
||||
private const uint ConfigPageId = 0x10000213u;
|
||||
|
||||
/// <summary>The tab host's close (X) button — fires the SAME
|
||||
/// <c>ToggleOptionsPanel</c> action as F11 and the toolbar button
|
||||
/// (research doc §2.3).</summary>
|
||||
private const uint CloseButtonId = 0x10000210u;
|
||||
|
||||
// Gameplay tab (0x2100002A) button ids — research doc §6, byte-verified
|
||||
// against the committed options_gameplay_2100002A.json fixture.
|
||||
private const uint ExitToCharacterSelectionId = 0x10000203u;
|
||||
private const uint ConfigureKeyboardId = 0x10000204u;
|
||||
private const uint InGameHelpFilesId = 0x10000205u;
|
||||
private const uint UrgentAssistanceId = 0x10000206u;
|
||||
private const uint ReportAbuseId = 0x10000207u;
|
||||
private const uint UseMouseTurningSettingsId = 0x100005CCu;
|
||||
private const uint ExitGameId = 0x10000617u;
|
||||
|
||||
/// <summary>Callback delegates this controller wires the seven Gameplay
|
||||
/// buttons and the close button to. Every field maps to exactly one
|
||||
/// button; a null field leaves that button INERT (authored, clickable,
|
||||
/// no handler) — the shape D5's In-Game Help Files and OP8's still-
|
||||
/// unimplemented Configure Keyboard both need.</summary>
|
||||
public sealed record Callbacks(
|
||||
Action Toggle,
|
||||
Action RequestExitToCharacterSelection,
|
||||
Action ExitGame,
|
||||
Action UseMouseTurningSettings,
|
||||
Action<string> DisplaySystemMessage,
|
||||
Action? AfterApply = null)
|
||||
{
|
||||
/// <summary>Urgent Assistance's own byte-verified retail failure text.</summary>
|
||||
public string UrgentAssistanceMessage { get; init; } =
|
||||
OptionsPanelText.UrgentAssistanceUnavailable;
|
||||
|
||||
/// <summary>Report Abuse's own byte-verified retail failure text.</summary>
|
||||
public string ReportAbuseMessage { get; init; } =
|
||||
OptionsPanelText.ReportAbuseUnavailable;
|
||||
}
|
||||
|
||||
private readonly UiTabPanel _tabPanel;
|
||||
private readonly Dictionary<uint, OptionPage> _pages = new();
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Root element of the imported panel (the tab host itself —
|
||||
/// this widget IS a <see cref="UiTabPanel"/>).</summary>
|
||||
public UiElement Root => _tabPanel;
|
||||
|
||||
/// <summary>The underlying tab-control widget, for callers that need
|
||||
/// direct tab-switch access (e.g. tests).</summary>
|
||||
public UiTabPanel TabPanel => _tabPanel;
|
||||
|
||||
/// <summary>Per-tab option-page models, keyed by page slot element id.
|
||||
/// Every entry exists from construction (Campaign OP slice OP3) even
|
||||
/// though only <see cref="GameplayPageId"/>'s stays permanently empty —
|
||||
/// Character/Chat/Config slices (OP4-6) register their rows into these
|
||||
/// SAME instances rather than re-deriving the page-tracking dictionary.</summary>
|
||||
public IReadOnlyDictionary<uint, OptionPage> Pages => _pages;
|
||||
|
||||
/// <summary>The Gameplay tab's page model — always empty (research doc
|
||||
/// §6: a pure button list, no <c>UIOption</c> rows, no Apply/Reset/
|
||||
/// Defaults). Exposed by name for tests exercising the empty-page case.</summary>
|
||||
public OptionPage GameplayPage => _pages[GameplayPageId];
|
||||
|
||||
public OptionPage CharacterPage => _pages[CharacterPageId];
|
||||
|
||||
public OptionPage ChatPage => _pages[ChatPageId];
|
||||
|
||||
public OptionPage ConfigPage => _pages[ConfigPageId];
|
||||
|
||||
private OptionsPanelController(UiTabPanel tabPanel, Action? afterApply)
|
||||
{
|
||||
_tabPanel = tabPanel;
|
||||
foreach (uint pageId in new[] { GameplayPageId, CharacterPageId, ChatPageId, ConfigPageId })
|
||||
{
|
||||
var page = new OptionPage { AfterApply = afterApply };
|
||||
_pages.Add(pageId, page);
|
||||
}
|
||||
|
||||
_tabPanel.ActivePageChanged += OnActivePageChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bind an imported <see cref="HostLayoutId"/>/<see cref="SlotElementId"/>
|
||||
/// layout to live behavior. <paramref name="layout"/>'s root MUST be the
|
||||
/// built <see cref="UiTabPanel"/> — the caller imports via
|
||||
/// <c>LayoutImporter.ImportInfos(dats, HostLayoutId, SlotElementId)</c>
|
||||
/// then <c>LayoutImporter.Build</c>, exactly like every other catalog-style
|
||||
/// import in this codebase (<see cref="CharacterController"/>,
|
||||
/// <see cref="RetailDialogFactory"/>'s dialog catalog).
|
||||
/// </summary>
|
||||
/// <returns>Null if <paramref name="layout"/>'s root did not build as a
|
||||
/// <see cref="UiTabPanel"/> (a missing/malformed LayoutDesc).</returns>
|
||||
public static OptionsPanelController? Bind(ImportedLayout layout, Callbacks callbacks)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
ArgumentNullException.ThrowIfNull(callbacks);
|
||||
|
||||
if (layout.Root is not UiTabPanel tabPanel)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"[D.2b] OptionsPanelController.Bind: root did not build as UiTabPanel "
|
||||
+ $"(actual type {layout.Root.GetType().Name}) — Options panel will not open.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var controller = new OptionsPanelController(tabPanel, callbacks.AfterApply);
|
||||
|
||||
if (layout.FindElement(CloseButtonId) is UiButton close)
|
||||
close.OnClick = callbacks.Toggle;
|
||||
|
||||
BindButton(layout, ExitToCharacterSelectionId, callbacks.RequestExitToCharacterSelection);
|
||||
// ConfigureKeyboardId: INERT this slice — authored, clickable, no
|
||||
// handler. OP8 wires the real Configure Keyboard screen; the campaign
|
||||
// cannot close with this button still inert (plan §4 OP3).
|
||||
// InGameHelpFilesId: INERT — retail's own KeyStone::OpenHelp fails
|
||||
// without the missing plugins\ACHelpPlugin.dll (D5, register row).
|
||||
BindButton(layout, UseMouseTurningSettingsId, callbacks.UseMouseTurningSettings);
|
||||
BindButton(layout, ExitGameId, callbacks.ExitGame);
|
||||
BindButton(layout, UrgentAssistanceId,
|
||||
() => callbacks.DisplaySystemMessage(callbacks.UrgentAssistanceMessage));
|
||||
BindButton(layout, ReportAbuseId,
|
||||
() => callbacks.DisplaySystemMessage(callbacks.ReportAbuseMessage));
|
||||
|
||||
return controller;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Activates the tab-switching behavior (idempotent — safe even if
|
||||
/// already active). Must run AFTER <see cref="Bind"/> so this
|
||||
/// controller's <see cref="OnActivePageChanged"/> subscription is in
|
||||
/// place before the default-entry switch fires (Gameplay's
|
||||
/// <see cref="OptionPage.OnShown"/> for the initial tab).
|
||||
/// </summary>
|
||||
public void ActivateTabs() => _tabPanel.ActivateTabBehavior();
|
||||
|
||||
private static void BindButton(ImportedLayout layout, uint elementId, Action? onClick)
|
||||
{
|
||||
if (onClick is null) return;
|
||||
if (layout.FindElement(elementId) is UiButton button)
|
||||
button.OnClick = onClick;
|
||||
else
|
||||
Console.WriteLine(
|
||||
$"[D.2b] OptionsPanelController: Gameplay-tab button 0x{elementId:X8} "
|
||||
+ "not found in the built layout — its handler was not wired.");
|
||||
}
|
||||
|
||||
private void OnActivePageChanged(uint previousPageElementId, uint newPageElementId)
|
||||
{
|
||||
// Retail PlayerOptionPage::OnVisibilityChanged(false) -> RestoreSavedValues:
|
||||
// leaving a page reverts its uncommitted edits.
|
||||
if (previousPageElementId != 0 && _pages.TryGetValue(previousPageElementId, out OptionPage? previous))
|
||||
previous.OnHidden();
|
||||
|
||||
// OnVisibilityChanged(true) -> SaveCurrentValues: entering a page
|
||||
// (including the initial default-tab activation, previous == 0)
|
||||
// applies + commits.
|
||||
if (_pages.TryGetValue(newPageElementId, out OptionPage? next))
|
||||
next.OnShown();
|
||||
}
|
||||
|
||||
/// <summary>Retail's whole-window close also hides whichever page slot
|
||||
/// is currently visible — same revert as a tab switch away.</summary>
|
||||
public void OnHidden()
|
||||
{
|
||||
if (_pages.TryGetValue(_tabPanel.ActivePageElementId, out OptionPage? page))
|
||||
page.OnHidden();
|
||||
}
|
||||
|
||||
/// <summary>Re-opening the window re-shows the last-active page — same
|
||||
/// apply+commit as a tab switch in.</summary>
|
||||
public void OnShown()
|
||||
{
|
||||
if (_pages.TryGetValue(_tabPanel.ActivePageElementId, out OptionPage? page))
|
||||
page.OnShown();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_tabPanel.ActivePageChanged -= OnActivePageChanged;
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,17 @@ public static class RetailPanelCatalog
|
|||
public const uint Magic = 13u;
|
||||
public const uint Vitae = 15u;
|
||||
|
||||
/// <summary>
|
||||
/// The retail Options panel's <c>gmPanelUI</c> slot key — byte-verified
|
||||
/// from the toolbar options button's OWN authored property
|
||||
/// <c>0x10000029 = 10</c> (fixture <c>toolbar_21000016.json</c>,
|
||||
/// element <c>0x1000019B</c>) and independently corroborated by
|
||||
/// <c>docs/research/2026-08-10-options-panel-structure.md</c> §1.4's
|
||||
/// decompiled slot-key table (host <c>0x2100006E</c>, slot
|
||||
/// <c>0x1000018D</c> → key 10). Campaign OP slice OP3.
|
||||
/// </summary>
|
||||
public const uint Options = 10u;
|
||||
|
||||
private static readonly (uint PanelId, string WindowName)[] Mounted =
|
||||
{
|
||||
(CharacterInformation, WindowNames.CharacterInformation),
|
||||
|
|
@ -28,6 +39,7 @@ public static class RetailPanelCatalog
|
|||
(Character, WindowNames.Character),
|
||||
(Magic, WindowNames.Spellbook),
|
||||
(Vitae, WindowNames.Vitae),
|
||||
(Options, WindowNames.Options),
|
||||
};
|
||||
|
||||
private static readonly (uint PanelId, string WindowName)[] Toolbar =
|
||||
|
|
@ -35,6 +47,7 @@ public static class RetailPanelCatalog
|
|||
(Inventory, WindowNames.Inventory),
|
||||
(Character, WindowNames.Character),
|
||||
(Magic, WindowNames.Spellbook),
|
||||
(Options, WindowNames.Options),
|
||||
};
|
||||
|
||||
public static IReadOnlyList<(uint PanelId, string WindowName)> MountedPanels => Mounted;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using System.Collections.Generic;
|
|||
using AcDream.App.Plugins;
|
||||
using AcDream.App.Combat;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Net;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Spells;
|
||||
using AcDream.App.UI.Layout;
|
||||
|
|
@ -143,6 +144,39 @@ public sealed record ToolbarRuntimeBindings(
|
|||
|
||||
public sealed record CharacterRuntimeBindings(CharacterSheetProvider Provider);
|
||||
|
||||
/// <summary>
|
||||
/// Campaign OP slice OP3 (2026-08-11): bindings the retail Options panel
|
||||
/// needs beyond what the generic <see cref="RetailPanelUiController"/> mount
|
||||
/// already supplies (retained-window registration, geometry, opacity — all
|
||||
/// shared with every other <c>gmPanelUI</c> sibling through
|
||||
/// <see cref="RetailUiRuntimeBindings"/> itself).
|
||||
/// </summary>
|
||||
/// <param name="CommandBus">Late-resolved live command surface — the SAME
|
||||
/// <c>Func<ICommandBus></c> provider pattern <see cref="ChatRuntimeBindings.CommandBus"/>
|
||||
/// already uses, since the live session's bus is not yet constructed at the
|
||||
/// point this binding record is built (composition order).</param>
|
||||
/// <param name="IsGrounded">Retail's mid-air logout-refusal check
|
||||
/// (<c>transient_state & 1</c>) — mirrors the SAME
|
||||
/// <c>d.PlayerMode.IsPlayerMode && d.PlayerController.Controller is
|
||||
/// { IsAirborne: false }</c> pattern already used for
|
||||
/// <c>InteractionRetainedUiComposition</c>'s own <c>playerOnGround</c> read.</param>
|
||||
/// <param name="IsUseMouseTurningEnabled">Live read of
|
||||
/// <c>PlayerOption.UseMouseTurning</c> (<c>CharacterOptions2</c> bit
|
||||
/// <c>0x00400000</c>) from the canonical <c>RuntimeCharacterOptionsState</c> —
|
||||
/// the mouse-turning macro's sixth, server-synced target.</param>
|
||||
/// <param name="DisplaySystemMessage">The interface-text seam
|
||||
/// (<c>RetailLogTextType.ClientLocal</c>, SpewBox-only) — the SAME
|
||||
/// <c>text => d.Communication.AddText(text, RetailLogTextType.ClientLocal)</c>
|
||||
/// delegate shape <see cref="VendorRuntimeBindings.DisplaySystemMessage"/>/
|
||||
/// <see cref="AppraisalRuntimeBindings"/> already use.</param>
|
||||
public sealed record OptionsRuntimeBindings(
|
||||
Func<ICommandBus> CommandBus,
|
||||
Func<bool> IsGrounded,
|
||||
Func<bool> IsUseMouseTurningEnabled,
|
||||
Action<string> DisplaySystemMessage,
|
||||
Func<CameraTurningSettings> LoadCameraTurning,
|
||||
Action<CameraTurningSettings> SaveCameraTurning);
|
||||
|
||||
public sealed record InventoryRuntimeBindings(
|
||||
ClientObjectTable Objects,
|
||||
Func<uint> PlayerGuid,
|
||||
|
|
@ -231,6 +265,7 @@ public sealed record RetailUiRuntimeBindings(
|
|||
RetailUiCursorBindings Cursor,
|
||||
ConfirmationRuntimeBindings Confirmations,
|
||||
AppraisalRuntimeBindings Appraisal,
|
||||
OptionsRuntimeBindings Options,
|
||||
StackSplitQuantityState StackSplitQuantity,
|
||||
BufferedUiRegistry? Plugins,
|
||||
RetailUiPersistenceBindings? Persistence,
|
||||
|
|
@ -309,6 +344,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
MountAppraisal();
|
||||
MountEffects();
|
||||
MountIndicatorDetailPanels();
|
||||
MountOptionsPanel();
|
||||
MountIndicators();
|
||||
MountJumpPowerbar();
|
||||
MountDialogFactory();
|
||||
|
|
@ -404,6 +440,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
public RetailDialogFactory? DialogFactory { get; private set; }
|
||||
public ExternalContainerController? ExternalContainerController { get; private set; }
|
||||
public VendorUiController? VendorController { get; private set; }
|
||||
public OptionsPanelController? OptionsPanelController { get; private set; }
|
||||
|
||||
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
|
||||
{
|
||||
|
|
@ -1783,25 +1820,178 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
Console.WriteLine("[UI] retail seven-control indicator bar from LayoutDesc 0x21000071.");
|
||||
}
|
||||
|
||||
private void RequestEndCharacterSession()
|
||||
/// <summary>
|
||||
/// Retail <c>ID_Client_EndCharacterSessionConfirm</c>
|
||||
/// (<c>CM_UI::SendNotice_EndCharacterSession</c>'s confirmation dialog
|
||||
/// text — research doc <c>2026-08-10-keyboard-config-and-gameplay-tab.md</c>
|
||||
/// §2.1), shared by both the indicator bar's own end-session control
|
||||
/// (<see cref="RequestEndCharacterSession"/>) and the Options panel's
|
||||
/// Exit to Character Selection button
|
||||
/// (<see cref="RequestExitToCharacterSelection"/>).
|
||||
/// </summary>
|
||||
private string ResolveEndCharacterSessionConfirmMessage()
|
||||
{
|
||||
const string fallback = "Are you sure you want to end this character session?";
|
||||
string message;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
message = new DatStringResolver(_bindings.Assets.Dats).Resolve(
|
||||
return new DatStringResolver(_bindings.Assets.Dats).Resolve(
|
||||
0x23000001u,
|
||||
DatStringResolver.ComputeHash("ID_Client_EndCharacterSessionConfirm"))
|
||||
?? fallback;
|
||||
}
|
||||
}
|
||||
|
||||
ShowConfirmation(message, accepted =>
|
||||
private void RequestEndCharacterSession()
|
||||
{
|
||||
ShowConfirmation(ResolveEndCharacterSessionConfirmMessage(), accepted =>
|
||||
{
|
||||
if (accepted)
|
||||
_bindings.Indicators.EndCharacterSession();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign OP slice OP3: the Options panel's Exit to Character
|
||||
/// Selection button (element <c>0x10000203</c>) — D6's "behaves as Exit
|
||||
/// Game" adaptation (register row, same commit), PLUS retail's
|
||||
/// confirmation dialog and mid-air refusal, which DO port exactly.
|
||||
/// <c>gmGamePlayUI::UseTime @0x004EA3A0</c>'s drain: on confirmation
|
||||
/// accept, refuse with <see cref="ClientTextRefusals.CantLogOffMidAir"/>
|
||||
/// via the interface-text seam while airborne
|
||||
/// (<c>transient_state & 1</c>); otherwise proceed exactly like Exit
|
||||
/// Game.
|
||||
/// </summary>
|
||||
private void RequestExitToCharacterSelection()
|
||||
{
|
||||
ShowConfirmation(ResolveEndCharacterSessionConfirmMessage(), accepted =>
|
||||
{
|
||||
if (!accepted) return;
|
||||
|
||||
if (_bindings.Options.IsGrounded())
|
||||
{
|
||||
_bindings.Indicators.EndCharacterSession();
|
||||
}
|
||||
else
|
||||
{
|
||||
_bindings.Options.DisplaySystemMessage(ClientTextRefusals.CantLogOffMidAir);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign OP slice OP3: the Options panel's Use Mouse Turning Settings
|
||||
/// button (element <c>0x100005CC</c>) — the pure
|
||||
/// <see cref="MouseTurningSettingsMacro"/> orchestrated against live
|
||||
/// bindings: load current values, compute the macro, persist + apply
|
||||
/// live, send the wire bit if it changed, emit every retail chat line,
|
||||
/// then commit the (currently empty) Config page exactly like retail's
|
||||
/// own trailing <c>SaveCurrentValues()</c>
|
||||
/// (<c>gmConfigUI::SetMouseTurningDefaults @0x0049EB57</c>).
|
||||
/// </summary>
|
||||
private void ApplyMouseTurningSettingsMacro()
|
||||
{
|
||||
CameraTurningSettings current = _bindings.Options.LoadCameraTurning();
|
||||
bool useMouseTurningCurrent = _bindings.Options.IsUseMouseTurningEnabled();
|
||||
MouseTurningSettingsMacro.Result result =
|
||||
MouseTurningSettingsMacro.Compute(current, useMouseTurningCurrent);
|
||||
|
||||
_bindings.Options.SaveCameraTurning(result.Updated);
|
||||
|
||||
if (result.UseMouseTurningChanged)
|
||||
{
|
||||
_bindings.Options.CommandBus().Publish(
|
||||
new SetSingleCharacterOptionRuntimeCmd(
|
||||
(uint)CharacterOptionId.UseMouseTurning, true));
|
||||
}
|
||||
|
||||
foreach (string line in result.ChatLines)
|
||||
_bindings.Options.DisplaySystemMessage(line);
|
||||
|
||||
OptionsPanelController?.ConfigPage.Apply();
|
||||
}
|
||||
|
||||
private void MountOptionsPanel()
|
||||
{
|
||||
ElementInfo? rootInfo;
|
||||
ImportedLayout? layout;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
rootInfo = LayoutImporter.ImportInfos(
|
||||
_bindings.Assets.Dats,
|
||||
Layout.OptionsPanelController.HostLayoutId,
|
||||
Layout.OptionsPanelController.SlotElementId);
|
||||
var resolver = new DatStringResolver(_bindings.Assets.Dats);
|
||||
layout = rootInfo is null
|
||||
? null
|
||||
: LayoutImporter.Build(
|
||||
rootInfo,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont,
|
||||
resolver.Resolve);
|
||||
}
|
||||
if (rootInfo is null || layout is null)
|
||||
{
|
||||
Console.WriteLine("[UI] options panel: LayoutDesc 0x2100006E slot 0x1000018D not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
var callbacks = new Layout.OptionsPanelController.Callbacks(
|
||||
Toggle: () => ToggleWindow(WindowNames.Options),
|
||||
RequestExitToCharacterSelection: RequestExitToCharacterSelection,
|
||||
ExitGame: _bindings.Indicators.EndCharacterSession,
|
||||
UseMouseTurningSettings: ApplyMouseTurningSettingsMacro,
|
||||
DisplaySystemMessage: _bindings.Options.DisplaySystemMessage,
|
||||
AfterApply: () => _bindings.Options.CommandBus().Publish(
|
||||
new SaveCharacterOptionsRuntimeCmd()));
|
||||
|
||||
Layout.OptionsPanelController? controller =
|
||||
Layout.OptionsPanelController.Bind(layout, callbacks);
|
||||
if (controller is null)
|
||||
{
|
||||
Console.WriteLine("[UI] options panel: required root did not build as UiTabPanel.");
|
||||
return;
|
||||
}
|
||||
|
||||
OptionsPanelController = controller;
|
||||
controller.ActivateTabs();
|
||||
|
||||
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||
Host.Root,
|
||||
controller.Root,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
new RetailWindowFrame.Options
|
||||
{
|
||||
WindowName = WindowNames.Options,
|
||||
Chrome = RetailWindowChrome.NineSlice,
|
||||
Left = 150f,
|
||||
Top = 80f,
|
||||
Visible = false,
|
||||
ResizeX = true,
|
||||
ResizeY = true,
|
||||
ResizableEdges = ResizeEdges.Left | ResizeEdges.Right
|
||||
| ResizeEdges.Top | ResizeEdges.Bottom,
|
||||
ConstrainDragToParent = true,
|
||||
ConstrainResizeToParent = true,
|
||||
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top
|
||||
| AnchorEdges.Right | AnchorEdges.Bottom,
|
||||
ContentClickThrough = false,
|
||||
MinWidth = 300f,
|
||||
MinHeight = 200f,
|
||||
DrawChromeCenter = !AuthorsFullPanelCenter(rootInfo),
|
||||
Controller = controller,
|
||||
});
|
||||
_panelUi.RegisterMainPanel(
|
||||
RetailPanelCatalog.Options,
|
||||
WindowNames.Options,
|
||||
handle,
|
||||
rootInfo.TryGetEffectiveBool(
|
||||
RetailPanelUiController.RestorePreviousPropertyId,
|
||||
out bool restorePrevious)
|
||||
&& restorePrevious);
|
||||
Console.WriteLine("[UI] retail Options panel from LayoutDesc importer (0x2100006E slot 0x1000018D).");
|
||||
}
|
||||
|
||||
private void MountJumpPowerbar()
|
||||
{
|
||||
ElementInfo? info;
|
||||
|
|
|
|||
|
|
@ -102,6 +102,19 @@ public sealed class UiTabPanel : UiDatElement, IUiChildrenAttachedListener
|
|||
/// direct <see cref="SwitchTo"/> call).</summary>
|
||||
public uint ActivePageElementId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Fires at the end of every <see cref="SwitchTo"/> call that actually
|
||||
/// changes the active page — including the FIRST switch, from
|
||||
/// <see cref="ActivateTabBehavior"/>'s default-entry activation (old = 0).
|
||||
/// A page-model owner (Campaign OP slice OP3's <c>OptionsPanelController</c>)
|
||||
/// subscribes here to drive retail's per-page
|
||||
/// <c>OnVisibilityChanged(false)</c>/<c>OnVisibilityChanged(true)</c> pair
|
||||
/// (research doc §3.6) on the leaving/entering page — this widget only
|
||||
/// owns which page slot is <see cref="UiElement.Visible"/>, not the
|
||||
/// page-model semantics layered on top of that switch.
|
||||
/// </summary>
|
||||
public event Action<uint, uint>? ActivePageChanged;
|
||||
|
||||
/// <summary>
|
||||
/// True once <see cref="ActivateTabBehavior"/> has run. Dormant instances (every
|
||||
/// pre-existing Type-8 host today) never flip this.
|
||||
|
|
@ -184,6 +197,7 @@ public sealed class UiTabPanel : UiDatElement, IUiChildrenAttachedListener
|
|||
public void SwitchTo(uint pageElementId)
|
||||
{
|
||||
if (ActivePageElementId == pageElementId) return;
|
||||
uint previousPageElementId = ActivePageElementId;
|
||||
|
||||
foreach (UiTabTableEntry entry in _tabs)
|
||||
{
|
||||
|
|
@ -196,6 +210,7 @@ public sealed class UiTabPanel : UiDatElement, IUiChildrenAttachedListener
|
|||
}
|
||||
|
||||
ActivePageElementId = pageElementId;
|
||||
ActivePageChanged?.Invoke(previousPageElementId, pageElementId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -27,4 +27,5 @@ public static class WindowNames
|
|||
public const string Vitae = "vitae";
|
||||
public const string Examination = "examination";
|
||||
public const string Vendor = "vendor";
|
||||
public const string Options = "options";
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue