User finding 3 (retail screenshot): hovering a town on the Map tab turns its marker GREEN and shows the name on a special-font tooltip — clearly not our generic 0x10000395 popup skin, and we had no hover highlight at all. Re-derivation (live-DAT probe + raw ElementDesc dump + surface byte-decode; MapNoteLiveDatTests pins all of it): - m_pMap (0x100001EC)'s P0x47/P0x48 = 0x100001F0 @ 0x21000026 are the note CONSTRUCTION template (AddMapNote @0x004a1bb0's CreateChildElement args) — that part we had right. - The TEMPLATE's own DirectState authors the note's tooltip popup locator P0x47=0x10000398/P0x48=0x21000041 — the FOURTH popup skin, whose incorporated text child 0x10000396 fonts 0x40000015 where the other three skins font 0x40000002 (the user's "special font") — plus P0x50=0.0 (zero per-element tooltip delay: town tooltips fire the instant the dwell arms; UiRoot already honors it), P0x4B TooltipOn, and P0x13 RolloverEnabled. Batch C's "the template authors no locator of its own" claim was WRONG, and BuildTownMarkers' hardcoded shared-skin override was clobbering the authored values — removed. - The hover highlight: the template's Normal/Normal_rollover states are PassToChildren descriptors driving the swallowed highlight child 0x100001F1 (base 0x100002B7@0x21000042 — a four-piece frame all drawing 0x06004CC9, byte-decoded PURE GREEN A=FF R=00 G=FF B=00) via per-state P0x3B (Invisible): hidden at rest, green on rollover. Port: - UiButton.CascadeStateToChildren — retail UIElement::SetState @0x00464E70's PassToChildren cascade, keyed off the REQUESTED state id (properties commit unconditionally; only the sprite draw is art-gated, the existing #382/AP-222 distinction). - UiDatElement.TrySetRetailState honors per-state P0x3B for NAMED states (OnSetAttribute @0x00462d80 case 8: SetVisible(value==0)). The unnamed-DirectState case is explicitly excluded — honoring it would un-gate ISSUES #408 (1,083 authored-invisible elements) through BuildWidget's post-children state reapply; measured breaking the spell-favorite drag tests before the scoping (note added to #408). - MapPageController.BuildTownMarkers rebuilds the button-swallowed highlight child per marker through the AD-108 IconBuilder seam (Bindings.TemplateInfoResolver, backed by RowTemplateResolver.ResolveInfo — same cache) and arms it with the initial Normal cascade. Register TS-85's Batch C paragraph corrected; RetailTooltipPresenter's F10 shared-skin remark updated (MapPageController no longer a consumer). Tests: 3 installed-DAT pins (locator/delay/rollover; per-state P0x3B + green frame; the four-skin font sweep), UiButton cascade + UiDatElement P0x3B units, MapHousePanel marker no-clobber + hover-highlight fixture. App suite 5487 passed / 3 skips (5490 total, +11 over baseline); Runtime 1744/1744. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1079 lines
51 KiB
C#
1079 lines
51 KiB
C#
using System;
|
|
using System.Numerics;
|
|
using AcDream.App.UI.Layout;
|
|
|
|
namespace AcDream.App.UI;
|
|
|
|
/// <summary>
|
|
/// Generic dat-widget button — the production replacement for any dat element of
|
|
/// Type 1 (UIElement_Button, registered via RegisterElementClass(1, UIElement_Button::Create)
|
|
/// @ acclient_2013_pseudo_c.txt:125828).
|
|
///
|
|
/// <para>
|
|
/// Draws per-state sprite media exactly like <see cref="UiDatElement"/> (same
|
|
/// <c>ActiveState</c> defaulting, same <c>ActiveMedia()</c> fallback chain, same tiled
|
|
/// <c>DrawSprite</c> call with UV-repeat so chrome edges tile correctly) plus an
|
|
/// optional centered text label. The click behavior mirrors <see cref="UiDatElement"/>
|
|
/// one-for-one so the chat Send and Max/Min buttons that previously bound through
|
|
/// <c>UiDatElement.OnClick</c> continue to work without behavioral change.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// State selection: picks <see cref="ElementInfo.DefaultStateName"/> if set, then
|
|
/// "Normal" if the element has a Normal state sprite, then falls back to the unnamed
|
|
/// DirectState ("" key) — identical to <see cref="UiDatElement"/>.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Built by <see cref="DatWidgetFactory"/> for Type-1 elements (chat Send 0x10000019,
|
|
/// Max/Min 0x1000046F). NOT the same as <see cref="UiSimpleButton"/>, which is an
|
|
/// earlier dev-scaffold widget with no dat sprites.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|
{
|
|
private readonly ElementInfo _info;
|
|
private readonly ElementInfo _mediaInfo;
|
|
private readonly FaceSegment[] _faceSegments;
|
|
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
|
|
private readonly HashSet<uint> _availableStates = new();
|
|
private readonly bool _hasCustomSelectionPair;
|
|
private IReadOnlyDictionary<uint, Vector4>? _stateLabelColors;
|
|
private IReadOnlyDictionary<uint, bool>? _stateLabelOutlines;
|
|
private bool _pressed;
|
|
private bool _pointerOver;
|
|
private bool _selected;
|
|
private bool _hotClicking;
|
|
private bool _suppressNextClick;
|
|
private int _pointerX;
|
|
private int _pointerY;
|
|
private double _nextHotClickTime = double.NaN;
|
|
|
|
/// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary>
|
|
public Action? OnClick { get; set; }
|
|
|
|
/// <summary>
|
|
/// Optional left-button double-click handler. Null preserves the existing
|
|
/// bubbling behavior; character-management row template 0x100003A5 opts in
|
|
/// for retail's element message 0x1A (activate the selected character).
|
|
/// </summary>
|
|
public Action? OnDoubleClick { get; set; }
|
|
|
|
/// <summary>
|
|
/// Optional right-click handler (Campaign OP slice OP8's Configure Keyboard
|
|
/// screen: right-click a bound key button to erase that one binding —
|
|
/// <c>UIOption_ActionKeyMap::EraseBinding @0x00487780</c>). Null by default,
|
|
/// so every pre-existing <see cref="UiButton"/> is unaffected — this only adds
|
|
/// a new optional event, it does not change any existing click/drag behavior.
|
|
/// </summary>
|
|
public Action? OnRightClick { get; set; }
|
|
|
|
/// <summary>
|
|
/// Optional pointer transition handlers. These expose retail's distinct
|
|
/// pressed/released element messages for controls such as the combat-height
|
|
/// buttons, where mouse-down begins charging and mouse-up commits the attack.
|
|
/// </summary>
|
|
public Action? OnPressed { get; set; }
|
|
public Action? OnReleased { get; set; }
|
|
|
|
/// <summary>
|
|
/// Optional position-aware click handler. Coordinates are local pixels in this button,
|
|
/// matching retail's <c>UIElementMessageInfo.ptWindow</c> paperdoll hit-test input.
|
|
/// </summary>
|
|
public Action<int, int>? OnClickAt { get; set; }
|
|
|
|
/// <summary>
|
|
/// Optional item-drop target callbacks. Retail uses ordinary buttons as drop surfaces
|
|
/// in a few panels (notably gmToolbarUI's inventory button), so this belongs on the
|
|
/// retained button rather than in UiRoot or a panel-specific hit-test workaround.
|
|
/// </summary>
|
|
public Func<ItemDragPayload, ItemDragAcceptance>? OnItemDragOver { get; set; }
|
|
public Action<ItemDragPayload>? OnItemDrop { get; set; }
|
|
public uint ItemDragAcceptSprite { get; set; }
|
|
public uint ItemDragRejectSprite { get; set; }
|
|
private ItemDragAcceptance _itemDragAcceptance;
|
|
|
|
internal ItemDragAcceptance ItemDragAcceptanceForTest => _itemDragAcceptance;
|
|
|
|
/// <summary>The dat element id from <see cref="ElementInfo.Id"/>.</summary>
|
|
public uint ElementId => _info.Id;
|
|
|
|
/// <summary>Optional centered text label drawn over the sprite (e.g. "Send" on a blank gold frame).</summary>
|
|
public string? Label { get; set; }
|
|
|
|
/// <summary>Dat font for <see cref="Label"/>. Required for the label to draw.</summary>
|
|
public UiDatFont? LabelFont { get; set; }
|
|
|
|
/// <summary>Label color (default white).</summary>
|
|
public Vector4 LabelColor { get; set; } = Vector4.One;
|
|
|
|
/// <summary>Settable tooltip, surfaced through the shared
|
|
/// <see cref="UiElement.GetTooltipText"/> hover pipeline (same pattern as
|
|
/// <see cref="UiCatalogSlot"/>). Retail option rows stamp theirs via
|
|
/// <c>UIElement::SetTooltip</c> during row construction
|
|
/// (e.g. <c>UIOption_CheckboxBitfield64::CreateChildren @0x00485DF0</c>).</summary>
|
|
public string? TooltipText { get; set; }
|
|
|
|
/// <inheritdoc />
|
|
public override string? GetTooltipText() =>
|
|
string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText;
|
|
|
|
/// <summary>Retail LayoutDesc property <c>0x21</c> (two-pass glyph outline,
|
|
/// <c>UIElement_Text::SetOutline @0x0046a81c</c>). Seeded by DatWidgetFactory
|
|
/// from the element's effective-default state, same as <see cref="UiText.Outline"/>
|
|
/// (round-5 review S2 — per-STATE switching is AP-192).</summary>
|
|
public bool Outline { get; set; }
|
|
|
|
/// <summary>Retail LayoutDesc property <c>0x22</c> (<c>m_curOutlineColor</c>,
|
|
/// ctor default black). Only meaningful when <see cref="Outline"/> is true.</summary>
|
|
public Vector4 OutlineColor { get; set; } = UiRenderContext.DefaultOutlineColor;
|
|
|
|
/// <summary>Optional authored face rectangle. Full-button by default; retail
|
|
/// UIOption_Checkbox uses its 13x13 indicator child as the button face.</summary>
|
|
public float FaceLeft { get; set; }
|
|
public float FaceTop { get; set; }
|
|
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>
|
|
/// Campaign CC gate round 1 closeout (Group 1, R2-5): per-instance
|
|
/// multiplicative sprite tint, threaded into every <see cref="UiRenderContext.DrawSprite"/>
|
|
/// call this class makes (main face, face-segment, drag-acceptance
|
|
/// overlay) — retail's own <c>SurfaceWindow::BlitAndColor(...,
|
|
/// Blit_Multiply, color)</c>. Default <see cref="Vector4.One"/> (white,
|
|
/// full alpha) leaves every DrawSprite call byte-identical to before
|
|
/// this property existed; only a caller that explicitly sets a
|
|
/// non-identity tint (e.g. <see cref="Layout.CharacterCreationAppearancePage"/>'s
|
|
/// color-wheel swatches) changes what draws.
|
|
/// </summary>
|
|
public Vector4 Tint { get; set; } = Vector4.One;
|
|
|
|
/// <summary>
|
|
/// R3-5 (Campaign CC gate round 1 re-test 2): optional resolver
|
|
/// returning a PRE-BAKED, already color-key-recolored texture handle
|
|
/// (from <see cref="AcDream.App.Rendering.TextureCache.UploadRgba8"/>
|
|
/// or equivalent), drawn UNTINTED (1:1, no UV repeat) INSTEAD of the
|
|
/// ordinary <see cref="FaceFileOverride"/>/<c>ActiveFile</c> sprite.
|
|
/// Retail's own <c>gmCGAppearancePage::DoColorSpots @0x0047d850</c>
|
|
/// does NOT multiply-tint the swatch's authored ring+spot sprite (a
|
|
/// multiply of a target color against BLACK — the spot template's own
|
|
/// placeholder fill, live-DAT-pixel-confirmed — stays black regardless
|
|
/// of the tint, and multiplying the ring's own non-black border pixels
|
|
/// shifts their hue/brightness, corrupting them). Retail instead calls
|
|
/// <c>SurfaceWindow::ReplaceColor</c>: build a fresh composited surface
|
|
/// once, blit the spot template onto it, then swap every EXACT-black
|
|
/// pixel for the swatch's real color — the ring border (never black)
|
|
/// is untouched. This property is that same mechanism's C# seam.
|
|
/// <see cref="Tint"/> itself is left completely unchanged in meaning
|
|
/// and is STILL the value callers set to communicate "this button's
|
|
/// color is X" (existing callers/tests that only read
|
|
/// <see cref="Tint"/> are unaffected) — this resolver is a SEPARATE
|
|
/// decision (deliberately not fed by <see cref="Tint"/>: a caller may
|
|
/// need to distinguish more states — e.g. "beyond count, show the
|
|
/// blocked art" versus "no color data yet, show nothing" — than one
|
|
/// Vector4 can encode) that only changes what OnDraw does when
|
|
/// non-null: consult it for a texture instead of directly multiplying
|
|
/// the authored sprite. Null (default, every pre-existing button)
|
|
/// preserves the exact prior FaceFileOverride/ActiveFile +
|
|
/// multiply-Tint draw.
|
|
/// </summary>
|
|
public Func<uint>? ColorKeyFaceResolver { get; set; }
|
|
|
|
/// <summary>Additional left inset for left-aligned labels.</summary>
|
|
public float LabelOffsetX { get; set; } = 3f;
|
|
|
|
/// <summary>Horizontal alignment of <see cref="Label"/>. Center (default) for normal buttons;
|
|
/// Left for the paperdoll "Slots" caption that sits at the left edge, before the slots.</summary>
|
|
public LabelAlignment LabelAlign { get; set; } = LabelAlignment.Center;
|
|
|
|
/// <summary>
|
|
/// GF-11c (Campaign CC gate round 1 Batch B): optional authored label
|
|
/// rectangle, LOCAL to this button. When a caption is LIFTED from a
|
|
/// DISTINCT Type-12 child that carries its own independent rect (e.g.
|
|
/// the Town page's per-marker name label, positioned below/beside its
|
|
/// marker rather than immediately right of it), <see cref="OnDraw"/>
|
|
/// draws the label within THIS box using its own authored geometry
|
|
/// instead of the FaceLeft-derived offset / full-button-width centering
|
|
/// the ordinary case uses (label authored directly on the button, right
|
|
/// beside a single-purpose face segment — the heritage/template/Face-
|
|
/// Clothes row family, where the current face-relative math is already
|
|
/// correct). Null (default, every pre-existing button) preserves the
|
|
/// EXACT prior draw math — <see cref="LabelAlignment.Left"/> still adds
|
|
/// <see cref="LabelOffsetX"/> to the button's own local origin, and
|
|
/// <see cref="LabelAlignment.Center"/> still centers within the whole
|
|
/// button width/height.
|
|
/// </summary>
|
|
public (float X, float Y, float Width, float Height)? LabelBox { get; set; }
|
|
|
|
/// <summary>
|
|
/// GF-4a (Campaign CC gate round 1 Batch C): optional secondary VALUE
|
|
/// text, coexisting with <see cref="Label"/> (the authored CAPTION).
|
|
/// Retail's chargen display buttons (Attribute/Skill Credits, Health,
|
|
/// Stamina, Mana — <c>0x100003e2-e5</c>, <c>0x100003f9</c>) author the
|
|
/// caption directly as this element's own dat property <c>0x17</c>
|
|
/// AND carry a SEPARATE, media-less Type-12 child for the live value
|
|
/// (<c>gmCGProfessionPage::InitializePage @0x00482f90-0x00483062</c>,
|
|
/// <c>gmCGSkillsPage::InitializePage @0x00481e1c</c>) —
|
|
/// <see cref="UiButton"/> consumes ALL of its dat children
|
|
/// (<see cref="ConsumesDatChildren"/>), which used to mean a page
|
|
/// controller had nowhere faithful to put the value except
|
|
/// overwriting <see cref="Label"/> itself, destroying the caption.
|
|
/// <see cref="Layout.DatWidgetFactory.BuildButton"/> now surfaces that
|
|
/// child's geometry/font/color here instead. Null (default) draws
|
|
/// nothing extra — every pre-existing button that only ever wrote
|
|
/// <see cref="Label"/> is unaffected.
|
|
/// </summary>
|
|
public string? ValueLabel { get; set; }
|
|
|
|
/// <summary>Dat font for <see cref="ValueLabel"/>.</summary>
|
|
public UiDatFont? ValueFont { get; set; }
|
|
|
|
/// <summary>Color for <see cref="ValueLabel"/> (default white).</summary>
|
|
public Vector4 ValueColor { get; set; } = Vector4.One;
|
|
|
|
/// <summary>Authored rectangle for <see cref="ValueLabel"/>, LOCAL to
|
|
/// this button — the lifted value child's own rect
|
|
/// (<see cref="Layout.DatWidgetFactory.BuildButton"/> sets this). Null
|
|
/// (no value child found) means <see cref="ValueLabel"/> is never set
|
|
/// either, so this is never read in that case.</summary>
|
|
public (float X, float Y, float Width, float Height)? ValueBox { get; set; }
|
|
|
|
/// <summary>Horizontal alignment of <see cref="ValueLabel"/> within
|
|
/// <see cref="ValueBox"/> — the lifted child's own authored justify.</summary>
|
|
public LabelAlignment ValueAlign { get; set; } = LabelAlignment.Center;
|
|
|
|
/// <summary>
|
|
/// Label horizontal alignment options. <see cref="Right"/> (R4-1, Campaign
|
|
/// CC gate round 1 re-test 3) is ValueLabel-only today — every value
|
|
/// child on the chargen credit-display family (0x100002f1/0x100002f3)
|
|
/// authors dat HJustify Right (raw 3/5), decomp-confirmed by
|
|
/// <c>UIElement_Text::CalcJustification @0x00467260</c>'s
|
|
/// <c>ecx_5==3||5</c> branch (<c>edi = availWidth - textWidth</c>, i.e.
|
|
/// flush to the box's own far edge) — distinct from Center's halved
|
|
/// offset. <see cref="LabelAlign"/> never authors Right today so no
|
|
/// existing switch over it needs a new arm.
|
|
/// </summary>
|
|
public enum LabelAlignment { Center, Left, Right }
|
|
|
|
public bool ToggleBehavior { get; }
|
|
public bool RolloverEnabled { get; }
|
|
public bool HotClickEnabled { get; }
|
|
public float HotClickInitialDelay { get; }
|
|
public float HotClickRepeatInterval { get; }
|
|
|
|
/// <summary>
|
|
/// Opt-out for a <see cref="ToggleBehavior"/> button whose <see cref="Selected"/>
|
|
/// state is a PURE MIRROR of external state (a producer other than the button's
|
|
/// own blind self-flip is the sole legitimate writer — e.g.
|
|
/// <see cref="ChatWindowController.SetIndicatorOpen"/> mirroring a floating chat
|
|
/// window's own visibility). CH6a/b REJECT-review SHOULD-FIX 3: the chat-window
|
|
/// 1-4 indicators (<c>0x10000522</c>-<c>0x10000525</c>) carry DAT property
|
|
/// <c>0x0B</c> (<see cref="ToggleBehavior"/>) = true, so without this flag a
|
|
/// click flips their Highlight/Normal art with no underlying visibility change —
|
|
/// the mirror lies until the next real toggle.
|
|
///
|
|
/// <para>
|
|
/// Round 4 (2026-08-10): this stays set for the chat-window indicators even
|
|
/// though clicking them now DOES toggle their floating window (via
|
|
/// <see cref="ChatWindowController.BindIndicatorClicks"/> — see
|
|
/// <see cref="ChatWindowController.SetIndicatorOpen"/>'s doc for the full retail
|
|
/// mechanism this reconciles). The click drives the real toggle, and
|
|
/// <c>SetIndicatorOpen</c> — the SAME single writer as before — reports the
|
|
/// outcome back onto <see cref="Selected"/>; this button's own MouseUp-time
|
|
/// blind flip stays suppressed so the visual never races or diverges from the
|
|
/// window's actual state. Every OTHER toggle button (max/min, checkboxes) keeps
|
|
/// retail's normal click-toggles-itself behavior; default
|
|
/// <see langword="false"/>.
|
|
/// </para>
|
|
/// </summary>
|
|
public bool SuppressSelfToggle { get; set; }
|
|
|
|
public bool Selected
|
|
{
|
|
get => _selected;
|
|
set
|
|
{
|
|
if (_selected == value) return;
|
|
_selected = value;
|
|
UpdateVisualState();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Active state name, runtime-settable (e.g. Max/Min toggling Normal ↔ Minimized).
|
|
/// Matches <see cref="UiDatElement.ActiveState"/>.
|
|
/// </summary>
|
|
public string ActiveState { get; set; } = "";
|
|
|
|
public uint ActiveRetailStateId
|
|
{
|
|
get
|
|
{
|
|
if (string.IsNullOrEmpty(ActiveState))
|
|
return UiStateInfo.DirectStateId;
|
|
foreach (var (id, state) in _mediaInfo.States)
|
|
if (string.Equals(state.Name, ActiveState, StringComparison.Ordinal))
|
|
return id;
|
|
return UiButtonStateMachine.TryStateId(ActiveState, out uint standard)
|
|
? standard
|
|
: RetailUiStateIds.TryStateId(ActiveState, out uint custom) ? custom : 0u;
|
|
}
|
|
}
|
|
|
|
/// <summary>Reads a resolved enum-valued DAT attribute from this button.</summary>
|
|
public bool TryGetEnumAttribute(uint propertyId, out uint value)
|
|
{
|
|
if (_info.TryGetEffectiveProperty(propertyId, out var property)
|
|
&& property.Kind == UiPropertyKind.Enum)
|
|
{
|
|
value = checked((uint)property.UnsignedValue);
|
|
return true;
|
|
}
|
|
|
|
value = 0;
|
|
return false;
|
|
}
|
|
|
|
public override string ActiveCursorStateName => ActiveState;
|
|
|
|
public bool TrySetRetailState(uint stateId)
|
|
{
|
|
if (ToggleBehavior && stateId is UiButtonStateMachine.Normal or UiButtonStateMachine.Highlight)
|
|
{
|
|
Selected = stateId == UiButtonStateMachine.Highlight;
|
|
return true;
|
|
}
|
|
if (stateId == UiButtonStateMachine.Ghosted)
|
|
{
|
|
Enabled = false;
|
|
return true;
|
|
}
|
|
if (!Enabled && stateId != UiButtonStateMachine.Ghosted)
|
|
Enabled = true;
|
|
|
|
if (stateId == UiStateInfo.DirectStateId)
|
|
{
|
|
// #382: a DirectState transition only actually applies when the button
|
|
// has REAL "" media to show. Every button structurally carries a
|
|
// DirectStateId entry in _mediaInfo.States purely as the property bag
|
|
// for its own base-level dat properties (ToggleBehavior 0x0B, RolloverEnabled
|
|
// 0x13, etc. — see UiButtonTests.AddBoolProperty), independent of whether
|
|
// it authors any blank/unnamed sprite. TryFindState(DirectStateId) succeeding
|
|
// on that property-only entry used to be enough to accept the transition
|
|
// (the retail-decompiled UIElement::SetState @0x00464e70 commits m_curStateDesc
|
|
// unconditionally once ElementDesc::AccessStateDesc finds ANY StateDesc, media
|
|
// or not — retail's OWN buttons dodge the resulting blank draw purely through
|
|
// construction TIMING: Initialize()'s SetState(m_defaultState) call happens
|
|
// before m_children is populated, so a PassToChildren cascade from an ancestor
|
|
// can never reach an already-initialized child during import). Our port's
|
|
// LayoutImporter.BuildWidget deliberately reapplies a PARENT's default AFTER its
|
|
// children are built (so retained PassToChildren tabs get their authored
|
|
// Open/Closed child media — see that method's own comment), which means a
|
|
// chrome ancestor's structural (media-less) DirectState — e.g. the floating
|
|
// chat window's indicator-button backing panel, which authors PassToChildren=
|
|
// true on its own empty DirectState purely to route HideDetail/ShowDetail to
|
|
// an unrelated sibling — can and does reach an already-correctly-resolved
|
|
// button (ActiveState="Normal") and blank it before first paint. Requiring
|
|
// actual "" media closes that gap without touching the reapply ordering (which
|
|
// CharacterStatController's three-chrome-children PassToChildren cascade still
|
|
// depends on) or the cascade mechanism itself (both remain faithful ports).
|
|
if (!HasStateMedia(""))
|
|
return false;
|
|
ActiveState = "";
|
|
CascadeStateToChildren(stateId);
|
|
return true;
|
|
}
|
|
if (TryFindState(stateId, out var state))
|
|
{
|
|
ActiveState = state.Name;
|
|
CascadeStateToChildren(stateId);
|
|
return true;
|
|
}
|
|
string stateName = UiButtonStateMachine.StateName(stateId);
|
|
if (string.IsNullOrEmpty(stateName))
|
|
stateName = RetailUiStateIds.StateName(stateId);
|
|
if (!string.IsNullOrEmpty(stateName) && HasStateMedia(stateName))
|
|
{
|
|
ActiveState = stateName;
|
|
CascadeStateToChildren(stateId);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
|
|
/// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height).
|
|
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
|
|
public UiButton(
|
|
ElementInfo info,
|
|
Func<uint, (uint tex, int w, int h)> resolve,
|
|
ElementInfo? mediaInfo = null,
|
|
IReadOnlyList<ElementInfo>? faceSegments = null)
|
|
{
|
|
_info = info;
|
|
_mediaInfo = mediaInfo ?? info;
|
|
_faceSegments = faceSegments is null
|
|
? []
|
|
: faceSegments.Select(static segment => new FaceSegment(segment)).ToArray();
|
|
_resolve = resolve;
|
|
ClickThrough = false; // buttons are interactive — opt OUT of click-through
|
|
|
|
// Visual transitions can select only states with an actual button face.
|
|
// Retail layouts commonly declare an empty Normal_pressed descriptor while
|
|
// supplying art only for Normal/Highlight. Treating that property-only state
|
|
// as drawable briefly blanks the button during mouse-down.
|
|
if (_faceSegments.Length == 0)
|
|
AddAvailableStates(_mediaInfo);
|
|
else
|
|
foreach (FaceSegment segment in _faceSegments)
|
|
AddAvailableStates(segment.Info);
|
|
|
|
// Campaign CC gate round 1 Batch B (GF-1/GF-8): retail's custom
|
|
// "Unselected"/"Selected" radio-selection state pair
|
|
// (RetailUiStateIds.Unselected/Selected, 0x10000016/0x10000017) is
|
|
// authored as STATE DESCRIPTORS whose names UiButtonStateMachine's
|
|
// Normal/Highlight machine doesn't recognize — the standard
|
|
// AddAvailableStates loop above never admits them, so the ordinary
|
|
// RequestedState()-driven UpdateVisualState can never select them
|
|
// (measured: Selected=true committed nothing against the installed
|
|
// dat before this fix). HasStateMedia already checks the same media
|
|
// presence (face-segment child OR the button's own StateMedia) used
|
|
// everywhere else in this class, so this reuses that exact
|
|
// detection rather than adding a new one.
|
|
_hasCustomSelectionPair = HasStateMedia(RetailUiStateIds.StateName(RetailUiStateIds.Unselected))
|
|
&& HasStateMedia(RetailUiStateIds.StateName(RetailUiStateIds.Selected));
|
|
|
|
ToggleBehavior = info.TryGetEffectiveBool(0x0Bu, out bool toggle) && toggle;
|
|
RolloverEnabled = info.TryGetEffectiveBool(0x13u, out bool rollover) && rollover;
|
|
HotClickEnabled = info.TryGetEffectiveBool(0x0Fu, out bool hotClick) && hotClick;
|
|
HotClickInitialDelay = info.TryGetEffectiveFloat(0x10u, out float initialDelay)
|
|
? initialDelay
|
|
: 0f;
|
|
HotClickRepeatInterval = info.TryGetEffectiveFloat(0x11u, out float repeatInterval)
|
|
? repeatInterval
|
|
: 0f;
|
|
_selected = info.TryGetEffectiveBool(0x0Eu, out bool selected) && selected;
|
|
bool disabled = info.TryGetEffectiveBool(0x0Du, out bool ghosted) && ghosted;
|
|
|
|
// State defaulting matches UiDatElement exactly:
|
|
// DefaultStateName wins; else "Normal" if that state has a sprite; else DirectState ("").
|
|
if (!string.IsNullOrEmpty(info.DefaultStateName))
|
|
ActiveState = info.DefaultStateName;
|
|
else if (HasStateMedia("Normal"))
|
|
ActiveState = "Normal";
|
|
// else ActiveState stays "" (DirectState)
|
|
|
|
Enabled = !disabled;
|
|
FaceWidth = mediaInfo?.Width ?? info.Width;
|
|
FaceHeight = mediaInfo?.Height ?? info.Height;
|
|
UpdateVisualState();
|
|
}
|
|
|
|
/// <summary>The button draws its own face + label; any dat label child is reproduced
|
|
/// procedurally, so the importer must not build the button's children as widgets.</summary>
|
|
public override bool ConsumesDatChildren => true;
|
|
|
|
/// <summary>A button is interactive — it must receive its Click even inside a whole-window-Draggable
|
|
/// frame (e.g. the paperdoll "Slots" toggle in the inventory window), so it opts out of the
|
|
/// IA-12 whole-window-drag that would otherwise swallow the press.</summary>
|
|
public override bool HandlesClick => true;
|
|
|
|
/// <summary>
|
|
/// Returns the File id for the current <see cref="ActiveState"/>, falling back to
|
|
/// the DirectState ("" key) if the named state is absent.
|
|
/// Returns 0 if neither exists.
|
|
/// Mirrors <see cref="UiDatElement.ActiveMedia()"/>.
|
|
/// </summary>
|
|
private uint ActiveFile(ElementInfo mediaInfo)
|
|
=> mediaInfo.StateMedia.TryGetValue(ActiveState, out var m) ? m.File
|
|
: mediaInfo.StateMedia.TryGetValue("", out var d) ? d.File : 0u;
|
|
|
|
protected override void OnDraw(UiRenderContext ctx)
|
|
{
|
|
if (_faceSegments.Length != 0)
|
|
{
|
|
foreach (FaceSegment segment in _faceSegments)
|
|
DrawFace(ctx, ActiveFile(segment.Info), segment.Rect(Width, Height));
|
|
}
|
|
else if (ColorKeyFaceResolver is { } colorKeyResolver)
|
|
{
|
|
// R3-5: a pre-baked, already-recolored texture (see this
|
|
// property's own doc) — drawn UNTINTED and 1:1 (no UV repeat;
|
|
// the baked bitmap is uploaded at its own native size, which
|
|
// for the chargen swatches equals the button's own authored
|
|
// rect, live-DAT-measured).
|
|
uint bakedTexture = colorKeyResolver();
|
|
if (bakedTexture != 0)
|
|
{
|
|
float faceWidth = FaceWidth > 0f ? FaceWidth : Width;
|
|
float faceHeight = FaceHeight > 0f ? FaceHeight : Height;
|
|
ctx.DrawSprite(bakedTexture, FaceLeft, FaceTop, faceWidth, faceHeight,
|
|
0f, 0f, 1f, 1f, Vector4.One);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
uint file = FaceFileOverride ?? ActiveFile(_mediaInfo);
|
|
if (file != 0)
|
|
{
|
|
var (tex, tw, th) = _resolve(file);
|
|
if (tex != 0 && tw != 0 && th != 0)
|
|
{
|
|
// Tiled draw — same call shape as UiDatElement.OnDraw (UV-repeat; GL_REPEAT-wrapped
|
|
// UI texture). Matches ImgTex::TileCSI; no Stretch mode exists.
|
|
float faceWidth = FaceWidth > 0f ? FaceWidth : Width;
|
|
float faceHeight = FaceHeight > 0f ? FaceHeight : Height;
|
|
ctx.DrawSprite(tex, FaceLeft, FaceTop, faceWidth, faceHeight,
|
|
0, 0, faceWidth / tw, faceHeight / th, Tint);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Label is { Length: > 0 } label && LabelFont is { } lf)
|
|
{
|
|
// GF-11c: LabelBox null (every pre-existing button) reduces boxX/
|
|
// boxY to 0 and boxWidth/boxHeight to the button's own Width/
|
|
// Height — byte-identical to the prior unconditional math.
|
|
float boxX = LabelBox?.X ?? 0f;
|
|
float boxY = LabelBox?.Y ?? 0f;
|
|
float boxWidth = LabelBox?.Width ?? Width;
|
|
float boxHeight = LabelBox?.Height ?? Height;
|
|
|
|
// R2-2/R2-3 (Campaign CC gate round 1 Batch E) + R3-2 correction
|
|
// (re-test 2): when this button ALSO carries a coexisting
|
|
// ValueLabel (GF-4a's own-caption + separate value slot — the
|
|
// Profession attribute/health/stamina/mana credits buttons, the
|
|
// Skills credits button), boxWidth still narrows to stop before
|
|
// the value's authored rect for the (currently unused, since
|
|
// every known ValueBox button is Left-aligned) Center-tx
|
|
// formula and the explicit-newline clip rect below — see
|
|
// DrawBlockLabel's own doc for why this no longer gates
|
|
// WHETHER a single-line caption wraps or clips (R3-2: it never
|
|
// did in retail — live-DAT-measured, "Available Skill Credits"
|
|
// fits the button's own full 231px width with room to spare).
|
|
if (ValueBox is { X: var valueBoxX } && valueBoxX > boxX)
|
|
boxWidth = MathF.Min(boxWidth, valueBoxX - boxX);
|
|
|
|
DrawBlockLabel(ctx, label, lf, LabelColor, boxX, boxY, boxWidth, boxHeight, LabelAlign, LabelOffsetX);
|
|
}
|
|
|
|
if (ValueLabel is { Length: > 0 } value && ValueFont is { } vf)
|
|
{
|
|
float boxX = ValueBox?.X ?? 0f;
|
|
float boxY = ValueBox?.Y ?? 0f;
|
|
float boxWidth = ValueBox?.Width ?? Width;
|
|
float boxHeight = ValueBox?.Height ?? Height;
|
|
float valueWidth = vf.MeasureWidth(value);
|
|
// R4-1: Right mirrors CalcJustification's own far-edge formula
|
|
// (box's own right edge minus the measured text width, no
|
|
// decorative inset — the decomp's Right branch adds none either,
|
|
// and this box carries no threaded marginR of its own).
|
|
float vx = ValueAlign switch
|
|
{
|
|
LabelAlignment.Left => boxX + LabelOffsetX,
|
|
LabelAlignment.Right => boxX + boxWidth - valueWidth,
|
|
_ => boxX + (boxWidth - valueWidth) * 0.5f,
|
|
};
|
|
float vy = boxY + (boxHeight - vf.LineHeight) * 0.5f;
|
|
ctx.DrawStringDat(vf, value, vx, vy, ValueColor, Outline, OutlineColor);
|
|
}
|
|
|
|
uint dragSprite = _itemDragAcceptance switch
|
|
{
|
|
ItemDragAcceptance.Accept => ItemDragAcceptSprite,
|
|
ItemDragAcceptance.Reject => ItemDragRejectSprite,
|
|
_ => 0u,
|
|
};
|
|
if (dragSprite != 0)
|
|
{
|
|
var (tex, _, _) = _resolve(dragSprite);
|
|
if (tex != 0)
|
|
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Tint);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// R2-2 (Campaign CC gate round 1 Batch E) + R3-1/R3-2 (re-test 2
|
|
/// correction): retail's <c>UIElement_Button</c> IS a
|
|
/// <c>UIElement_Text</c> (<c>struct UIElement_Button : UIElement_Text</c>,
|
|
/// <c>acclient.h</c>) — a caption that carries an authored newline
|
|
/// (already normalized to a real <c>'\n'</c> by
|
|
/// <see cref="Layout.DatWidgetFactory"/>'s shared
|
|
/// <c>ResolveAuthoredString</c>) lays out as multiple stacked lines. A
|
|
/// single line that already fits draws with byte-identical geometry to
|
|
/// the pre-Batch-E unconditional one-line math (same centered-block Y,
|
|
/// same tx formula).
|
|
/// <para>
|
|
/// Batch E ALSO auto-wrapped a paragraph that doesn't fit
|
|
/// <paramref name="boxWidth"/> via <see cref="UiText.WrapWords"/> — re-
|
|
/// derived at re-test 2 (R3-1 "Coordination"/R3-2 "Available Skill
|
|
/// Credits") as the wrong shape and REMOVED: live-DAT-probed, the
|
|
/// Coordination slider label (<c>0x100002ed</c>) authors <c>OneLine=
|
|
/// true</c> (dat property <c>0x20</c>) and the Skills credits button
|
|
/// (<c>0x100003f9</c>) authors <c>OneLine=false</c> yet BOTH render one
|
|
/// line in retail. Tracing <c>GlyphList::Recalculate
|
|
/// @0x00473800</c>'s per-glyph loop: the ENTIRE width-triggered break
|
|
/// decision (and, separately, the explicit-newline break) sits behind
|
|
/// one gate, <c>if (arg3 == 0)</c> where <c>arg3</c> is the SAME
|
|
/// <c>OneLine</c> boolean passed in from
|
|
/// <c>UIElement_Text::ResizeToPaper</c>/<c>InqSize</c> — i.e. a
|
|
/// caption's width is measured against its own FULL element rect (minus
|
|
/// margins), never against a sibling/child element's geometry; nothing
|
|
/// in the decomp confines a caption's wrap width to stop before another
|
|
/// element's rect. The 193px "Available Skill Credits" caption fits the
|
|
/// button's own full 231px width (live-DAT-measured) with room to
|
|
/// spare — it never needed to wrap at all. So: split ONLY on the
|
|
/// explicit <c>\n</c> (never invoke <see cref="UiText.WrapWords"/>) — a
|
|
/// strict superset of the pre-Batch-E single-line draw for every
|
|
/// caption that was already correct, and the exact shape "Attribute\n
|
|
/// Credits" (an authored break) still needs.
|
|
/// </para>
|
|
/// <para>
|
|
/// R3-2 deliberately does NOT clip a single (unwrapped) line to
|
|
/// <paramref name="boxWidth"/> either, even when the caller narrowed it
|
|
/// via a coexisting <see cref="ValueBox"/> — clipping would cut the
|
|
/// caption's own tail off mid-word, which contradicts "retail is ONE
|
|
/// line" just as much as wrapping does (a viewer would call that
|
|
/// truncated, not "one line"). The 193px-in-231px Skills-credits
|
|
/// geometry means the caption's rendered span (x≈3 to x≈196) does
|
|
/// overlap the value's own rect (x=116 to x=150, live-DAT-measured) in
|
|
/// principle — Batch E's own diagnosis of the ORIGINAL R2-2/R2-3
|
|
/// "24dits"/"Credit0Credits" reports. That overlap is NOT re-solved
|
|
/// here: this fix only removes the false wrap this specific finding
|
|
/// (R3-2) reported, and inventing an unevidenced clip boundary to
|
|
/// pre-empt a DIFFERENT, not-currently-reported symptom would be
|
|
/// exactly the guessing this project's workflow forbids. Flagged in
|
|
/// the findings doc for the user's own re-check once the wrap is gone.
|
|
/// </para>
|
|
/// </summary>
|
|
private void DrawBlockLabel(
|
|
UiRenderContext ctx,
|
|
string text,
|
|
UiDatFont font,
|
|
Vector4 color,
|
|
float boxX,
|
|
float boxY,
|
|
float boxWidth,
|
|
float boxHeight,
|
|
LabelAlignment align,
|
|
float leftOffset)
|
|
{
|
|
IReadOnlyList<(string Text, float X, float Y)> lines = WrapBlockLines(
|
|
text, font.MeasureWidth, font.LineHeight,
|
|
boxX, boxY, boxWidth, boxHeight, align, leftOffset);
|
|
|
|
// A multi-line result (an authored '\n') clips to its own box — the
|
|
// button's normal draw has no ambient clip, and an oversized
|
|
// wrapped caption (e.g. the Skills credits button's own tight 28px
|
|
// height) should be cut off at the box edge rather than spill into
|
|
// whatever sits below the button, matching every other clipped
|
|
// Type-12 text box in this codebase (UiText.DrawText's own
|
|
// PushClip). Single-line captions — the overwhelming majority,
|
|
// and (post-R3-2) EVERY caption with no authored newline — never
|
|
// pay this cost; see this method's own doc for why a single line
|
|
// is deliberately left unclipped even when boxWidth was narrowed.
|
|
bool clip = lines.Count > 1;
|
|
if (clip)
|
|
ctx.PushClip(boxX, boxY, boxWidth, boxHeight);
|
|
try
|
|
{
|
|
foreach ((string line, float tx, float ty) in lines)
|
|
ctx.DrawStringDat(font, line, tx, ty, color, Outline, OutlineColor);
|
|
}
|
|
finally
|
|
{
|
|
if (clip)
|
|
ctx.PopClip();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pure geometry half of <see cref="DrawBlockLabel"/> — split ONLY on an
|
|
/// authored explicit <c>'\n'</c>, then block-centered vertically within
|
|
/// <paramref name="boxHeight"/>. Pulled out as a static/pure method
|
|
/// (same shape as <see cref="UiText.ContentOffsetX"/>) so the geometry
|
|
/// is unit-testable without a font atlas or draw context —
|
|
/// <paramref name="measureWidth"/> takes the place of
|
|
/// <see cref="UiDatFont.MeasureWidth(string)"/>.
|
|
/// <para>
|
|
/// R3-1/R3-2 (re-test 2): deliberately does NOT width-wrap a paragraph
|
|
/// that overflows <paramref name="boxWidth"/> — see
|
|
/// <see cref="DrawBlockLabel"/>'s own doc for the decomp citation
|
|
/// (<c>GlyphList::Recalculate</c>'s width-triggered break sits behind
|
|
/// the SAME <c>OneLine</c> gate as the explicit-newline break, and
|
|
/// retail never confines a caption's wrap width to a sibling element's
|
|
/// rect). A paragraph that overflows still draws as one line, unclipped
|
|
/// by width — matching every plain (no authored <c>\n</c>) button
|
|
/// caption in retail, which is never observed to wrap.
|
|
/// </para>
|
|
/// </summary>
|
|
internal static IReadOnlyList<(string Text, float X, float Y)> WrapBlockLines(
|
|
string text,
|
|
Func<string, float> measureWidth,
|
|
float lineHeight,
|
|
float boxX,
|
|
float boxY,
|
|
float boxWidth,
|
|
float boxHeight,
|
|
LabelAlignment align,
|
|
float leftOffset)
|
|
{
|
|
string[] lines = text.Split('\n');
|
|
|
|
float totalHeight = lines.Length * lineHeight;
|
|
float startY = boxY + (boxHeight - totalHeight) * 0.5f;
|
|
|
|
var result = new List<(string, float, float)>(lines.Length);
|
|
for (int i = 0; i < lines.Length; i++)
|
|
{
|
|
string line = lines[i];
|
|
float tx = align == LabelAlignment.Left
|
|
? boxX + leftOffset
|
|
: boxX + (boxWidth - measureWidth(line)) * 0.5f;
|
|
float ty = startY + i * lineHeight;
|
|
result.Add((line, tx, ty));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private void DrawFace(UiRenderContext ctx, uint file, UiPixelRect rect)
|
|
{
|
|
if (file == 0 || rect.Width <= 0 || rect.Height <= 0)
|
|
return;
|
|
var (texture, textureWidth, textureHeight) = _resolve(file);
|
|
if (texture == 0 || textureWidth == 0 || textureHeight == 0)
|
|
return;
|
|
|
|
// Same tiled/cropped media path as UiDatElement. Segment geometry is
|
|
// first reflowed by its own four-edge retail layout policy.
|
|
ctx.DrawSprite(texture, rect.X0, rect.Y0, rect.Width, rect.Height,
|
|
0f, 0f, (float)rect.Width / textureWidth, (float)rect.Height / textureHeight,
|
|
Tint);
|
|
}
|
|
|
|
private void AddAvailableStates(ElementInfo mediaInfo)
|
|
{
|
|
foreach (string stateName in mediaInfo.StateMedia.Keys)
|
|
if (UiButtonStateMachine.TryStateId(stateName, out uint stateId))
|
|
_availableStates.Add(stateId);
|
|
}
|
|
|
|
private bool HasStateMedia(string stateName)
|
|
{
|
|
if (_faceSegments.Length == 0)
|
|
return _mediaInfo.StateMedia.ContainsKey(stateName);
|
|
foreach (FaceSegment segment in _faceSegments)
|
|
if (segment.Info.StateMedia.ContainsKey(stateName))
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
private bool TryFindState(uint stateId, out UiStateInfo state)
|
|
{
|
|
if (_faceSegments.Length == 0)
|
|
return _mediaInfo.States.TryGetValue(stateId, out state!);
|
|
foreach (FaceSegment segment in _faceSegments)
|
|
if (segment.Info.States.TryGetValue(stateId, out state!))
|
|
return true;
|
|
state = null!;
|
|
return false;
|
|
}
|
|
|
|
private sealed class FaceSegment
|
|
{
|
|
private readonly UiPixelRect _original;
|
|
private readonly UiLayoutPolicy? _layout;
|
|
|
|
public FaceSegment(ElementInfo info)
|
|
{
|
|
Info = info;
|
|
_original = UiPixelRect.FromPositionAndSize(
|
|
(int)info.X, (int)info.Y, (int)info.Width, (int)info.Height);
|
|
if (info.HasOriginalParentSize)
|
|
_layout = new UiLayoutPolicy(
|
|
info.Left, info.Top, info.Right, info.Bottom,
|
|
_original,
|
|
UiPixelRect.FromPositionAndSize(
|
|
0, 0, (int)info.OriginalParentWidth, (int)info.OriginalParentHeight));
|
|
}
|
|
|
|
public ElementInfo Info { get; }
|
|
|
|
public UiPixelRect Rect(float parentWidth, float parentHeight)
|
|
{
|
|
if (_layout is null)
|
|
return _original;
|
|
return _layout.Apply(
|
|
_original,
|
|
UiPixelRect.FromPositionAndSize(0, 0, (int)parentWidth, (int)parentHeight));
|
|
}
|
|
}
|
|
|
|
public override bool OnEvent(in UiEvent e)
|
|
{
|
|
switch (e.Type)
|
|
{
|
|
case UiEventType.HoverEnter:
|
|
_pointerOver = true;
|
|
UpdateVisualState();
|
|
return true;
|
|
case UiEventType.HoverLeave:
|
|
_pointerOver = false;
|
|
UpdateVisualState();
|
|
return true;
|
|
case UiEventType.MouseDown:
|
|
_pointerX = e.Data1;
|
|
_pointerY = e.Data2;
|
|
_pointerOver = ContainsLocal(e.Data1, e.Data2);
|
|
_pressed = true;
|
|
UpdateVisualState();
|
|
if (Enabled)
|
|
OnPressed?.Invoke();
|
|
if (HotClickEnabled && Enabled)
|
|
{
|
|
OnClick?.Invoke();
|
|
OnClickAt?.Invoke(_pointerX, _pointerY);
|
|
_hotClicking = true;
|
|
_nextHotClickTime = double.NaN;
|
|
}
|
|
return true;
|
|
case UiEventType.MouseMove:
|
|
if (_pressed)
|
|
{
|
|
_pointerX = e.Data1;
|
|
_pointerY = e.Data2;
|
|
_pointerOver = ContainsLocal(e.Data1, e.Data2);
|
|
UpdateVisualState();
|
|
return true;
|
|
}
|
|
return false;
|
|
case UiEventType.MouseUp:
|
|
_pointerX = e.Data1;
|
|
_pointerY = e.Data2;
|
|
_pointerOver = ContainsLocal(e.Data1, e.Data2);
|
|
_suppressNextClick = _hotClicking && _pointerOver;
|
|
_hotClicking = false;
|
|
_nextHotClickTime = double.NaN;
|
|
if (_pressed && _pointerOver && Enabled && ToggleBehavior && !SuppressSelfToggle)
|
|
_selected = !_selected;
|
|
if (_pressed && Enabled)
|
|
OnReleased?.Invoke();
|
|
_pressed = false;
|
|
UpdateVisualState();
|
|
return true;
|
|
case UiEventType.Click:
|
|
if (!Enabled) return true;
|
|
if (_suppressNextClick)
|
|
{
|
|
_suppressNextClick = false;
|
|
return true;
|
|
}
|
|
OnClick?.Invoke();
|
|
OnClickAt?.Invoke(e.Data1, e.Data2);
|
|
return OnClick is not null || OnClickAt is not null;
|
|
case UiEventType.DoubleClick:
|
|
if (OnDoubleClick is null) return false;
|
|
if (!Enabled) return true;
|
|
OnDoubleClick.Invoke();
|
|
return true;
|
|
case UiEventType.RightClick:
|
|
// S6 (2026-08-11 review): unlike Click (whose swallow-when-
|
|
// disabled is pre-existing, harmless-by-construction behavior
|
|
// every button already had), RightClick was UNHANDLED before
|
|
// this class grew OnRightClick — it fell through to `default:
|
|
// return false` and bubbled to the parent. Preserve that for
|
|
// every button with no handler, disabled or not, so this
|
|
// addition is genuinely a no-op for every pre-existing button
|
|
// (matching this property's own doc comment) and only changes
|
|
// behavior for the ones that opt in.
|
|
if (OnRightClick is null) return false;
|
|
if (!Enabled) return true;
|
|
OnRightClick.Invoke();
|
|
return true;
|
|
case UiEventType.DragEnter:
|
|
_itemDragAcceptance = e.Payload is ItemDragPayload payload
|
|
? OnItemDragOver?.Invoke(payload) ?? ItemDragAcceptance.None
|
|
: ItemDragAcceptance.None;
|
|
return OnItemDragOver is not null;
|
|
case UiEventType.DragOver:
|
|
_itemDragAcceptance = ItemDragAcceptance.None;
|
|
return OnItemDragOver is not null;
|
|
case UiEventType.DropReleased:
|
|
_itemDragAcceptance = ItemDragAcceptance.None;
|
|
if (e.Payload is ItemDragPayload dropped)
|
|
OnItemDrop?.Invoke(dropped);
|
|
return OnItemDrop is not null;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
protected override void OnEnabledChanged()
|
|
{
|
|
if (!Enabled)
|
|
{
|
|
_pressed = false;
|
|
_hotClicking = false;
|
|
_nextHotClickTime = double.NaN;
|
|
}
|
|
UpdateVisualState();
|
|
}
|
|
|
|
internal int FaceSegmentCount => _faceSegments.Length;
|
|
|
|
internal IReadOnlyList<UiPixelRect> FaceSegmentRectsForTest()
|
|
=> _faceSegments.Select(segment => segment.Rect(Width, Height)).ToArray();
|
|
|
|
public void OnGlobalUiTime(double nowSeconds)
|
|
{
|
|
if (!_hotClicking || !HotClickEnabled)
|
|
return;
|
|
|
|
if (double.IsNaN(_nextHotClickTime))
|
|
_nextHotClickTime = nowSeconds + HotClickInitialDelay;
|
|
|
|
if (!_pointerOver && nowSeconds >= _nextHotClickTime)
|
|
{
|
|
_nextHotClickTime = nowSeconds;
|
|
return;
|
|
}
|
|
|
|
if (_pointerOver && nowSeconds >= _nextHotClickTime)
|
|
{
|
|
OnClick?.Invoke();
|
|
OnClickAt?.Invoke(_pointerX, _pointerY);
|
|
_nextHotClickTime += HotClickRepeatInterval;
|
|
}
|
|
}
|
|
|
|
private bool ContainsLocal(int x, int y)
|
|
=> x >= 0 && y >= 0 && x < Width && y < Height;
|
|
|
|
private void UpdateVisualState()
|
|
{
|
|
uint requested = ComputeRequestedStateId();
|
|
if (_hasCustomSelectionPair)
|
|
{
|
|
// gmCGHeritagePage::Update @0x00483219-0x0048372D (and the
|
|
// mirrored template/sub-tab/gender call sites): retail sets
|
|
// this pair directly by SELECTION, not through the ordinary
|
|
// Normal/Highlight/rollover/pressed machine — these buttons
|
|
// never author rollover or pressed media for the pair, so
|
|
// there is nothing faithful to compute beyond selected-or-not.
|
|
ActiveState = RetailUiStateIds.StateName(requested);
|
|
}
|
|
else if (_availableStates.Contains(requested))
|
|
{
|
|
ActiveState = UiButtonStateMachine.StateName(requested);
|
|
}
|
|
|
|
// AP-222: apply the per-state label style off the REQUESTED id, not
|
|
// the (possibly art-gated) committed ActiveState — retail's own
|
|
// SetState(6) commits the state's PROPERTIES (including text color)
|
|
// unconditionally; only the SPRITE draw silently no-ops when a
|
|
// state has no media (this class's own #382 comment on
|
|
// TrySetRetailState documents the same distinction). The
|
|
// Appearance spins' current-part highlight is exactly this case:
|
|
// _availableStates never contains Highlight (their arrow face
|
|
// segments carry no Highlight art), so ActiveState stays "Normal"
|
|
// forever, but the spin's OWN label color must still swap.
|
|
ApplyPerStateLabelStyle(requested);
|
|
|
|
// Same unconditional-commit principle for the child cascade: retail
|
|
// UIElement::SetState @0x00464E70 propagates the committed state to
|
|
// children whenever the StateDesc authors PassToChildren, regardless
|
|
// of whether THIS element's own sprite changed — keyed off the
|
|
// REQUESTED id for the same reason as the label style above.
|
|
CascadeStateToChildren(requested);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail <c>UIElement::SetState @0x00464E70</c>'s PassToChildren
|
|
/// cascade, ported for buttons (2026-08-17 morning gate finding 3 —
|
|
/// previously only <see cref="Layout.UiDatElement.TrySetRetailState"/>
|
|
/// had it). First consumer: the map town-hotspot template
|
|
/// (<c>0x100001F0</c> in <c>0x21000026</c>) authors media-less
|
|
/// <c>Normal</c>/<c>Normal_rollover</c> state descriptors with
|
|
/// <c>PassToChildren=true</c> whose only job is driving the highlight
|
|
/// child's per-state <c>P0x3B</c> visibility flip (the green
|
|
/// <c>0x06004CC9</c> rollover frame). No-op unless a state descriptor
|
|
/// for <paramref name="stateId"/> exists AND authors PassToChildren AND
|
|
/// this button actually has stateful children — dat-built buttons
|
|
/// normally have none (<see cref="ConsumesDatChildren"/>); only
|
|
/// explicitly-attached subtrees (the map markers' highlight, the
|
|
/// AD-108 icon seam) can receive the cascade.
|
|
/// </summary>
|
|
private void CascadeStateToChildren(uint stateId)
|
|
{
|
|
if (Children.Count == 0)
|
|
return;
|
|
if (!TryFindState(stateId, out UiStateInfo state) || !state.PassToChildren)
|
|
return;
|
|
foreach (UiElement child in Children)
|
|
if (child is IUiDatStateful stateful)
|
|
stateful.TrySetRetailState(stateId);
|
|
}
|
|
|
|
private uint ComputeRequestedStateId()
|
|
=> _hasCustomSelectionPair
|
|
? (_selected ? RetailUiStateIds.Selected : RetailUiStateIds.Unselected)
|
|
: UiButtonStateMachine.RequestedState(new UiButtonVisualInput(
|
|
Disabled: !Enabled,
|
|
Selected: _selected,
|
|
RolloverEnabled: RolloverEnabled,
|
|
Pressed: _pressed,
|
|
PointerOver: _pointerOver));
|
|
|
|
/// <summary>
|
|
/// AP-222 / GF-11b (Campaign CC gate round 1 Batch B): optional per-
|
|
/// RETAIL-STATE label color/outline override, additive over the single
|
|
/// default <see cref="LabelColor"/>/<see cref="Outline"/> lifted once at
|
|
/// construction. Set by <see cref="Layout.DatWidgetFactory"/> ONLY when
|
|
/// the authored dat genuinely carries more than one distinct value
|
|
/// across this button's (or its lifted caption child's) own states —
|
|
/// e.g. the Appearance spins' Highlight-state gold brightening
|
|
/// (dat properties <c>0x1B</c>/<c>0x21</c>, live-DAT-measured
|
|
/// 218,167,85 -> 255,221,131 plus outline off -> on) or the Town
|
|
/// buttons' Normal-to-white caption swap (218,167,85 -> 255,255,255).
|
|
/// A button with a single authored color (the overwhelming majority)
|
|
/// never calls this, so <see cref="LabelColor"/>/<see cref="Outline"/>
|
|
/// keep behaving exactly as before — including every existing external
|
|
/// post-construction assignment (e.g. <c>ChatWindowController</c>'s Send
|
|
/// caption, <c>PaperdollController</c>'s Slots label), none of which
|
|
/// author a second distinct per-state color.
|
|
/// </summary>
|
|
internal void SetPerStateLabelStyle(
|
|
IReadOnlyDictionary<uint, Vector4>? colors,
|
|
IReadOnlyDictionary<uint, bool>? outlines)
|
|
{
|
|
_stateLabelColors = colors;
|
|
_stateLabelOutlines = outlines;
|
|
ApplyPerStateLabelStyle(ComputeRequestedStateId());
|
|
}
|
|
|
|
private void ApplyPerStateLabelStyle(uint requestedStateId)
|
|
{
|
|
if (_stateLabelColors is { } colors && colors.TryGetValue(requestedStateId, out Vector4 color))
|
|
LabelColor = color;
|
|
if (_stateLabelOutlines is { } outlines && outlines.TryGetValue(requestedStateId, out bool outline))
|
|
Outline = outline;
|
|
}
|
|
}
|