Retail authors ONE caption element per bar with per-mode state strings, switched by a PassToChildren state cascade (gmPowerbarUI:: RecvNotice_BeginPowerbar @0x004DA730 sets 0x10000042 Jump / 0x10000043 Melee / 0x10000044 Missile / 0x10000045 DDD; installed-DAT probe confirmed every string + PassToChildren flag). - Jump bar (user gate): the floaty powerbar's caption child (0x10000035: JumpMode 'Height', authored HJustify=Center over the bar) was dropped by UiMeter's child absorption. The stateful-fill meter build now absorbs it into per-state labels; TrySetRetailState latches the caption and OnDraw shows it when no live Label provider is bound. JumpPowerbarController's existing JumpMode flip now surfaces 'Height' with zero controller changes. The mount gained the string resolver the Build call never passed. - Combat bar (user gate): label 0x10000052 authors 'MeleeCombat' -> 'Power' and 'MissileCombat' -> 'Accuracy'; the controller latched the MELEE string once at bind. CombatUiLabels now resolves both authored strings and OnCombatModeChanged sets the mode's string - switching live when swapping melee <-> missile weapons in combat. Also fixed the mode-state flip target: the states live on the BASIC PANEL (0x1000005C, PassToChildren), not the layout root (Hide/ShowDetail only) - the old _root flip was a silent no-op. New env-gated ACDREAM_PROBE_POWERBAR layout probe (kept, house pattern). App suite 4,987/3 skips. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
913 lines
47 KiB
C#
913 lines
47 KiB
C#
using System;
|
|
using System.Linq;
|
|
using AcDream.App.UI;
|
|
|
|
namespace AcDream.App.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Hybrid factory: behavioral element Types map to dedicated widgets (verbatim
|
|
/// algorithm ports); everything else (and unknown Types) falls back to
|
|
/// <see cref="UiDatElement"/>.
|
|
///
|
|
/// <para>
|
|
/// Type 12 = UIElement_Text. Editable `0x16` elements become <see cref="UiField"/>
|
|
/// in place; other elements become display/selectable <see cref="UiText"/> widgets.
|
|
/// Elements that carry their own DAT sprite media keep it as widget background art.
|
|
/// Pure prototype elements draw nothing because text backgrounds default transparent.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// The meter's back/front 3-slice sprite ids live on grandchild image elements,
|
|
/// NOT on the meter element itself (format doc §11). <see cref="BuildMeter"/>
|
|
/// walks two layers down to extract them: the two Type-3 container children
|
|
/// ordered by <see cref="ElementInfo.ReadOrder"/> (back behind = lower, front
|
|
/// on top = higher), then within each container the image children that carry
|
|
/// a DirectState ("" key) sprite, ordered by their X position to obtain
|
|
/// left-cap / center-tile / right-cap.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// The expand-detail overlay present in the front container carries ONLY named
|
|
/// states ("HideDetail"/"ShowDetail") — no "" DirectState entry — so the
|
|
/// <c>TryGetValue("")</c> filter in <see cref="SliceIds"/> excludes it
|
|
/// automatically.
|
|
/// </para>
|
|
/// </summary>
|
|
public static class DatWidgetFactory
|
|
{
|
|
/// <summary>
|
|
/// Creates the <see cref="UiElement"/> for <paramref name="info"/>, sets its
|
|
/// rect (Left/Top/Width/Height) and Anchors, and returns it.
|
|
/// </summary>
|
|
/// <param name="info">Resolved, merged element snapshot from the LayoutDesc importer.</param>
|
|
/// <param name="resolve">RenderSurface id → (GL tex handle, pixel width, pixel height).
|
|
/// Returns (0,0,0) when the texture is not yet uploaded.</param>
|
|
/// <param name="datFont">Retail UI font for the meter's "cur/max" number overlay.
|
|
/// May be null pre-load — the meter falls back to the debug bitmap font.</param>
|
|
/// <param name="fontResolve">Optional font resolver: FontDid → <see cref="UiDatFont"/>
|
|
/// (or null when the font can't be loaded). When non-null, any element whose
|
|
/// <see cref="ElementInfo.FontDid"/> is non-zero gets ITS OWN dat font applied instead of
|
|
/// the shared <paramref name="datFont"/> fallback. Null = original behavior (use
|
|
/// <paramref name="datFont"/> for every element).</param>
|
|
/// <returns>The widget for this element. Never null — every type produces a widget.</returns>
|
|
public static UiElement? Create(ElementInfo info,
|
|
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont,
|
|
Func<uint, UiDatFont?>? fontResolve = null,
|
|
Func<UiStringInfoValue, string?>? stringResolve = null)
|
|
{
|
|
// Retail Type 3 = UIElement_Field (reg :126190), but in acdream's CURRENT layouts
|
|
// (vitals 0x2100006C / chat 0x2100006F) Type-3 elements are sprite-bearing chrome +
|
|
// containers (the 8-piece bevel corners/edges, the transcript/input panels), NOT
|
|
// editable fields — retail draws those as inert media-bearing Fields, which our
|
|
// UiDatElement reproduces pixel-for-pixel (and without the spurious focus/edit
|
|
// affordance a UiField would add). The one true editable field, the chat input
|
|
// (0x10000016), resolves to Type 12 and is controller-placed as a UiField. So Type 3
|
|
// stays on the generic fallback here; register it as UiField only when a window
|
|
// actually carries a factory-built editable Type-3 field (and UiField grows a
|
|
// background-media draw + an opt-in editable flag at that point). UiField (the widget)
|
|
// still ships — it just isn't wired into the factory switch yet.
|
|
// Resolve this element's own dat font if a resolver is provided and the element
|
|
// has a FontDid. Falls back to the shared datFont when not set (FontDid==0) or
|
|
// when the resolver returns null (font missing from dats).
|
|
UiDatFont? elementFont = datFont;
|
|
if (fontResolve is not null && info.FontDid != 0)
|
|
elementFont = fontResolve(info.FontDid) ?? datFont;
|
|
|
|
UiElement e = info.Type switch
|
|
{
|
|
UiRadar.RetailClassId => new UiRadar(), // gmRadarUI (Register 0x004D8B80)
|
|
1 => BuildButton(info, resolve, elementFont, fontResolve, stringResolve), // UIElement_Button
|
|
2 => new UiDatElement(info, resolve) // UIElement_Dragbar (Register @ 0x0046C840)
|
|
{
|
|
// The authored window-move handle: it must claim the pointer
|
|
// (UiDatElement defaults to ClickThrough decoration) so a press
|
|
// starts the window move and hover shows the move cursor
|
|
// (StartMouseMoving @ 0x0046C760 → UIElement::StartMovement).
|
|
WindowMoveHandle = true,
|
|
ClickThrough = false,
|
|
},
|
|
IndicatorBarController.BurdenClassId
|
|
or IndicatorBarController.EffectsClassId
|
|
or IndicatorBarController.LinkClassId
|
|
or IndicatorBarController.MiniGameClassId
|
|
or IndicatorBarController.VitaeClassId => BuildButton(
|
|
info, resolve, elementFont, fontResolve, stringResolve),
|
|
// gmUIElement_*Indicator custom button classes
|
|
// UIElement_ListBox (Type 5). OP2 rework (docs/research/2026-08-11-op2-
|
|
// review-blast.md MUST-FIX 2): every pre-existing Type-5 element that
|
|
// reaches this factory already authors a non-empty row-template array
|
|
// (dat property 0x64) — the original "none currently reach this factory"
|
|
// premise was false. UiTemplateListBox now derives from UiDatElement and
|
|
// stays DORMANT (no viewport, no behavior change) until a controller calls
|
|
// AddItemFromTemplateList, so mapping every Type-5 element unconditionally
|
|
// is safe: an element with an empty TemplateList behaves EXACTLY like the
|
|
// pre-OP2 UiDatElement fallback (media drawn, ClickThrough=true, state
|
|
// propagation) because nothing ever activates it.
|
|
5 => new UiTemplateListBox(info, resolve, info.TemplateList, info.ScrollbarElementId),
|
|
6 => new UiMenu(), // UIElement_Menu (reg :120163)
|
|
7 => BuildMeter(info, resolve, elementFont, stringResolve), // UIElement_Meter
|
|
// UIElement_Panel (Type 8) — retail's tab-strip host (dat property 0x2E;
|
|
// research doc §1.3/§10.1). OP2 rework (docs/research/2026-08-11-op2-
|
|
// review-mechanism.md MUST-FIX 5): Type 8 is UIElement_Panel, NOT a class
|
|
// called "UIElement_TabControl" (that name does not exist in the named-
|
|
// retail PDB). UiTabPanel derives from UiDatElement and stays DORMANT (see
|
|
// its class doc) until a controller calls ActivateTabBehavior(), so mapping
|
|
// every Type-8 element unconditionally is safe for the same reason as the
|
|
// Type-5 arm above — including the vendor backdrop 0x1000008D, which has NO
|
|
// tab table and now keeps its authored DirectState fill via the UiDatElement
|
|
// base instead of losing it to a bare UiElement with no OnDraw.
|
|
8 => new UiTabPanel(info, resolve, info.TabTable),
|
|
9 => BuildResizeGrip(info, resolve), // UIElement_Resizebar (reg 0x0046B920)
|
|
0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf
|
|
11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137)
|
|
12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text
|
|
0x13 => new UiDialogRoot(), // ConfirmationDialog
|
|
0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots
|
|
0x10000035u => BuildCheckbox(
|
|
info, resolve, elementFont, fontResolve, stringResolve), // UIOption_Checkbox
|
|
// UIOption_CheckboxSlider (Type 0x10000036): a composite row whose class id
|
|
// lands on the row root itself, but whose content is two NESTED option
|
|
// widgets (a UIOption_Checkbox child + a UIOption_Slider child — verified
|
|
// against options_2100002B.json's templates 0x10000220/0x10000221). It does
|
|
// not consume its dat children, so those build normally through the two
|
|
// mappings immediately below; UiOptionToggleSlider just grabs references to
|
|
// them once attached (see docs/research/2026-08-10-options-panel-structure.md
|
|
// §1.1/§1.5).
|
|
0x10000036u => new UiOptionToggleSlider(),
|
|
// UIOption_Slider (Type 0x10000037): structurally an ordinary HORIZONTAL
|
|
// UIElement_Scrollbar — its own DirectState carries the track sprite and its
|
|
// child id 1 is the drag thumb, the exact convention BuildScrollbar's
|
|
// horizontal branch already implements (verified against
|
|
// options_2100002B.json's slider control 0x1000021C: W=120 > H=12, one
|
|
// Type-1 child at id 1). No new drawing code — same "compose existing
|
|
// primitives" directive as UiOptionToggleSlider above.
|
|
0x10000037u => BuildScrollbar(info, resolve),
|
|
// UIOption_Menu (Type 0x10000038): a label + arrow-cap dropdown button,
|
|
// structurally identical to the vendor category dropdown UiMenu already
|
|
// models (verified against options_2100002B.json's menu control 0x10000224:
|
|
// a Text label child + a 17x19 image child, matching UiMenu's own
|
|
// ArrowCapClosedSprite doc comment). Built blank, exactly like the Type-6
|
|
// case above — a page controller wires its sprites/items the same way
|
|
// ChatWindowController wires the channel menu.
|
|
0x10000038u => new UiMenu(),
|
|
// UIOption_CheckboxBitfield64 (Type 0x10000044): the Chat tab's per-window
|
|
// text-filter block. OP2 rework (docs/research/2026-08-11-op2-review-
|
|
// mechanism.md MUST-FIX 4): the authored template (0x10000520) DOES author
|
|
// content — its own row-template list (dat property 0x64 -> {0x2100002B,
|
|
// 0x10000521}) — and retail CreateChildren @0x00485DF0 builds every row
|
|
// through AddItemFromTemplateList(this, 0, nullptr), matching retail's own
|
|
// gmChatOptionsUI::AddCheckboxBitfield64Option call pattern (research doc
|
|
// §5.2). AddChild(lowMask, highMask, label, tooltip) resolves that SAME
|
|
// template per row instead of synthesizing a fake ElementInfo.
|
|
// AP-195 (OP5): the block ALSO authors its own all/partial-set LED media
|
|
// (dat properties 0x10000082/0x10000083) directly on this element — thread
|
|
// them through so Refresh's ported LED swap has real sprites to apply.
|
|
0x10000044u => new UiCheckboxBitfield64(
|
|
info.TemplateList, info.LedCheckedSprite, info.LedUncheckedSprite),
|
|
_ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)
|
|
};
|
|
|
|
e.DatElementId = info.Id;
|
|
e.SetStateCursors(info.StateCursors);
|
|
|
|
// Propagate position + size (pixel-exact from the dat).
|
|
e.Left = info.X;
|
|
e.Top = info.Y;
|
|
e.Width = info.Width;
|
|
e.Height = info.Height;
|
|
|
|
// Honor the dat's draw order. ZLevel is the primary layer (higher = further BACK — e.g. the
|
|
// gmInventoryUI full-window backdrop at ZLevel 100 sits behind the ZLevel-0 panels, #145);
|
|
// ReadOrder is the within-layer tiebreaker (higher = on top). K=10000 exceeds any window's
|
|
// element count so ZLevel always dominates. Vitals (all ZLevel 0) keep ZOrder == ReadOrder.
|
|
e.ZOrder = (int)info.ReadOrder - (int)info.ZLevel * 10000;
|
|
|
|
// Map the four raw edge-anchor values to the AnchorEdges bit-flag that the
|
|
// compatibility layout engine uses for programmatic overrides.
|
|
e.Anchors = ElementReader.ToAnchors(info.Left, info.Top, info.Right, info.Bottom);
|
|
|
|
// Imported descendants use the exact four-mode retail policy. Roots have no
|
|
// design parent and intentionally remain on the compatibility path until a
|
|
// window mount assigns its own outer-frame policy.
|
|
if (info.HasOriginalParentSize)
|
|
e.LayoutPolicy = CreateLayoutPolicy(info);
|
|
|
|
return e;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bind inherited scrollbar media structurally. Property 0x77 names the
|
|
/// increment button and 0x78 the decrement button; retail
|
|
/// <c>UIElement_Scrollbar::UpdateScrollingArea @ 0x00470AA0</c> then places
|
|
/// those referenced child buttons by their authored leading/trailing
|
|
/// positions. The remaining Type-1 child is the thumb with ordered
|
|
/// top/middle/bottom image slices.
|
|
/// </summary>
|
|
private static UiScrollbar BuildScrollbar(
|
|
ElementInfo info,
|
|
Func<uint, (uint tex, int w, int h)> resolve)
|
|
{
|
|
var bar = new UiScrollbar
|
|
{
|
|
SpriteResolve = resolve,
|
|
TrackSprite = DefaultImage(info),
|
|
Horizontal = info.Width > info.Height,
|
|
};
|
|
|
|
uint incrementId = ReferencedElementId(info, 0x77u);
|
|
uint decrementId = ReferencedElementId(info, 0x78u);
|
|
ElementInfo? increment = info.Children.FirstOrDefault(child => child.Id == incrementId);
|
|
ElementInfo? decrement = info.Children.FirstOrDefault(child => child.Id == decrementId);
|
|
ElementInfo? leadingButton = new[] { increment, decrement }
|
|
.Where(child => child is not null)
|
|
.OrderBy(child => bar.Horizontal ? child!.X : child!.Y)
|
|
.ThenBy(child => child!.ReadOrder)
|
|
.FirstOrDefault();
|
|
ElementInfo? trailingButton = new[] { increment, decrement }
|
|
.Where(child => child is not null)
|
|
.OrderByDescending(child => bar.Horizontal ? child!.X : child!.Y)
|
|
.ThenByDescending(child => child!.ReadOrder)
|
|
.FirstOrDefault();
|
|
bar.UpSprite = ButtonStateImage(leadingButton, "Normal");
|
|
bar.UpRolloverSprite = ButtonStateImage(leadingButton, "Normal_rollover");
|
|
bar.UpPressedSprite = ButtonStateImage(leadingButton, "Normal_pressed");
|
|
bar.DownSprite = ButtonStateImage(trailingButton, "Normal");
|
|
bar.DownRolloverSprite = ButtonStateImage(trailingButton, "Normal_rollover");
|
|
bar.DownPressedSprite = ButtonStateImage(trailingButton, "Normal_pressed");
|
|
if (info.TryGetEffectiveBool(0x79u, out bool hideDisabled))
|
|
bar.HideWhenDisabled = hideDisabled;
|
|
|
|
if (bar.Horizontal)
|
|
{
|
|
if (leadingButton is { Width: > 0f })
|
|
bar.DecrementButtonExtent = leadingButton.Width;
|
|
if (trailingButton is { Width: > 0f })
|
|
bar.IncrementButtonExtent = trailingButton.Width;
|
|
|
|
// Retail horizontal scrollbars use structural child ids: element 1 is
|
|
// the thumb and element 4 is the optional child-authored track.
|
|
ElementInfo? scalarThumb = info.Children.FirstOrDefault(child => child.Id == 1u);
|
|
bar.TrackSprite = DefaultImage(info);
|
|
bar.ThumbSprite = scalarThumb is null ? 0u : DefaultImage(scalarThumb);
|
|
|
|
// The toolbar stack slider authors its track on structural child 4,
|
|
// while gmCombatUI authors it on the scrollbar's DirectState. Geometry
|
|
// is never the role discriminator: inheritance can reflow child 1 and
|
|
// otherwise turn the 12px combat jewel into a tiled background.
|
|
if (bar.TrackSprite == 0u)
|
|
{
|
|
ElementInfo? authoredTrack = info.Children.FirstOrDefault(child => child.Id == 4u);
|
|
bar.TrackSprite = authoredTrack is null ? 0u : DefaultImage(authoredTrack);
|
|
}
|
|
|
|
// gmCombatUI's desired-power slider (0x1000004F) authors the
|
|
// live charge as a nested Type-7 meter. UiScrollbar consumes its
|
|
// DAT children, so retain the meter's fill image on the scalar
|
|
// widget itself. The fill container is element 2; the Recklessness
|
|
// overlay (0x100005EF) is deliberately not the charge image.
|
|
ElementInfo? meter = info.Children.FirstOrDefault(child => child.Type == 7u);
|
|
ElementInfo? fill = meter?.Children.FirstOrDefault(child => child.Id == 2u)
|
|
?? meter?.Children
|
|
.Where(child => DefaultImage(child) != 0u)
|
|
.OrderByDescending(child => child.ReadOrder)
|
|
.FirstOrDefault();
|
|
bar.ScalarFillSprite = fill is null ? 0u : DefaultImage(fill);
|
|
|
|
// gmCombatUI preserves a second authored meter child for its dark
|
|
// red interior range. The widget retains the media because horizontal
|
|
// scrollbars consume their DAT children.
|
|
ElementInfo? scalarRange = meter?.Children.FirstOrDefault(
|
|
child => child.Id == 0x100005EFu);
|
|
bar.ScalarRangeSprite = scalarRange is null
|
|
? 0u
|
|
: DefaultImage(scalarRange);
|
|
bar.ScalarRangeLayoutPolicy = scalarRange is null
|
|
? null
|
|
: CreateLayoutPolicy(scalarRange);
|
|
// UIElement_Meter::UIElement_Meter @ 0x0046F4C0 defaults direction
|
|
// 1; DrawChildren @ 0x0046FBD0 clips that direction left-to-right.
|
|
// Direction 3 is the horizontal reverse. Read authored attribute
|
|
// 0x6F instead of inferring direction from the combat element id.
|
|
bar.ScalarFillFromRight = meter is not null
|
|
&& meter.TryGetEffectiveProperty(0x6Fu, out UiPropertyValue direction)
|
|
&& direction.Kind == UiPropertyKind.Enum
|
|
&& direction.UnsignedValue == 3u;
|
|
return bar;
|
|
}
|
|
|
|
if (leadingButton is { Height: > 0f })
|
|
bar.DecrementButtonExtent = leadingButton.Height;
|
|
if (trailingButton is { Height: > 0f })
|
|
bar.IncrementButtonExtent = trailingButton.Height;
|
|
|
|
ElementInfo? thumb = info.Children.FirstOrDefault(child =>
|
|
child.Type == 1u && child.Id != incrementId && child.Id != decrementId);
|
|
if (thumb is not null)
|
|
{
|
|
ElementInfo[] slices = thumb.Children
|
|
.Where(child => DefaultImage(child) != 0u)
|
|
.OrderBy(child => child.Y)
|
|
.ThenBy(child => child.ReadOrder)
|
|
.ToArray();
|
|
if (slices.Length > 0) bar.ThumbTopSprite = DefaultImage(slices[0]);
|
|
if (slices.Length > 1) bar.ThumbSprite = DefaultImage(slices[1]);
|
|
if (slices.Length > 2) bar.ThumbBotSprite = DefaultImage(slices[^1]);
|
|
}
|
|
|
|
return bar;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a <see cref="UiResizeGrip"/> from a Type-9 <c>UIElement_Resizebar</c>
|
|
/// element: retail <c>StartMouseResizing @0x0046B7E0</c> reads four BOOL
|
|
/// attributes — <c>0x2A</c>=bottom, <c>0x2B</c>=left, <c>0x2C</c>=right,
|
|
/// <c>0x2D</c>=top — and decodes them into a <c>BorderLocation</c>. A grip with
|
|
/// no true bool (the inert <c>_Locked</c> cosmetic twins are a DIFFERENT
|
|
/// element type entirely and never reach this factory case, but an
|
|
/// all-false Type-9 element is handled defensively the same way retail's own
|
|
/// BORDER_NONE fallback does) decodes to <see cref="UiResizeGrip.Border.None"/>
|
|
/// — <see cref="UiRoot"/> then treats it as contributing no resize edges.
|
|
/// <paramref name="resolve"/> is carried through (CH6a/b REJECT-review
|
|
/// BLOCKER 1) so the grip draws its own authored border/corner media
|
|
/// instead of nothing.
|
|
/// </summary>
|
|
private static UiResizeGrip BuildResizeGrip(
|
|
ElementInfo info, Func<uint, (uint tex, int w, int h)> resolve)
|
|
{
|
|
bool bottom = info.TryGetEffectiveBool(0x2Au, out bool bottomValue) && bottomValue;
|
|
bool left = info.TryGetEffectiveBool(0x2Bu, out bool leftValue) && leftValue;
|
|
bool right = info.TryGetEffectiveBool(0x2Cu, out bool rightValue) && rightValue;
|
|
bool top = info.TryGetEffectiveBool(0x2Du, out bool topValue) && topValue;
|
|
return new UiResizeGrip(info, resolve)
|
|
{
|
|
BorderLocation = UiResizeGrip.DecodeBorderLocation(bottom, left, right, top),
|
|
};
|
|
}
|
|
|
|
private static UiLayoutPolicy? CreateLayoutPolicy(ElementInfo info)
|
|
{
|
|
if (!info.HasOriginalParentSize) return null;
|
|
return new UiLayoutPolicy(
|
|
info.Left,
|
|
info.Top,
|
|
info.Right,
|
|
info.Bottom,
|
|
UiPixelRect.FromPositionAndSize(
|
|
(int)info.X,
|
|
(int)info.Y,
|
|
(int)info.Width,
|
|
(int)info.Height),
|
|
UiPixelRect.FromPositionAndSize(
|
|
0,
|
|
0,
|
|
(int)info.OriginalParentWidth,
|
|
(int)info.OriginalParentHeight));
|
|
}
|
|
|
|
private static uint ReferencedElementId(ElementInfo info, uint propertyId)
|
|
{
|
|
if (!info.TryGetEffectiveProperty(propertyId, out var property))
|
|
return 0u;
|
|
return property.Kind switch
|
|
{
|
|
UiPropertyKind.Enum or UiPropertyKind.DataId => (uint)property.UnsignedValue,
|
|
UiPropertyKind.Integer when property.IntegerValue >= 0 => (uint)property.IntegerValue,
|
|
_ => 0u,
|
|
};
|
|
}
|
|
|
|
private static uint DefaultImage(ElementInfo info)
|
|
{
|
|
uint stateId = info.EffectiveDefaultStateId();
|
|
if (info.States.TryGetValue(stateId, out var state) && state.Image is { } image)
|
|
return image.File;
|
|
if (info.States.TryGetValue(UiStateInfo.DirectStateId, out var direct)
|
|
&& direct.Image is { } directImage)
|
|
return directImage.File;
|
|
return 0u;
|
|
}
|
|
|
|
private static uint ButtonStateImage(ElementInfo? info, string stateName)
|
|
{
|
|
if (info is null)
|
|
return 0u;
|
|
if (info.StateMedia.TryGetValue(stateName, out var media))
|
|
return media.File;
|
|
UiStateInfo? state = info.States.Values.FirstOrDefault(
|
|
candidate => string.Equals(candidate.Name, stateName, StringComparison.Ordinal));
|
|
if (state?.Image is { } image)
|
|
return image.File;
|
|
return stateName == "Normal" ? DefaultImage(info) : 0u;
|
|
}
|
|
|
|
// ── Meter ────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Builds a <see cref="UiMeter"/> and populates its sprite ids from the meter's
|
|
/// child/grandchild elements (format doc §11). Two shapes are handled:
|
|
///
|
|
/// <para>
|
|
/// <b>3-slice shape</b> (vitals meters — 2 Type-3 containers, each with 3 image grandchildren):
|
|
/// <code>
|
|
/// meter (Type 7)
|
|
/// ├── back-layer container (Type 3, lower ReadOrder — drawn first / behind)
|
|
/// │ ├── left-cap image (DirectState "" → File = back-left sprite)
|
|
/// │ ├── center image (DirectState "" → File = back-tile sprite)
|
|
/// │ └── right-cap image (DirectState "" → File = back-right sprite)
|
|
/// ├── front-layer container (Type 3, higher ReadOrder — drawn on top)
|
|
/// │ ├── left-cap image (→ front-left sprite)
|
|
/// │ ├── center image (→ front-tile sprite)
|
|
/// │ ├── right-cap image (→ front-right sprite)
|
|
/// │ └── expand overlay (named "ShowDetail"/"HideDetail" only — NO DirectState — IGNORED)
|
|
/// └── text label (Type 0) (IGNORED — Fill/Label providers bound by VitalsController)
|
|
/// </code>
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Single-image shape</b> (toolbar selected-object meters 0x100001A1/0x100001A2 — 1 Type-3
|
|
/// child, no grandchildren): the back-track sprite is on the meter element's own DirectState;
|
|
/// the fill sprite is on the single Type-3 child's own DirectState. Both are placed in the
|
|
/// TILE slot (Back/FrontTile) with left/right caps 0, so <see cref="UiMeter.DrawHBar"/> tiles
|
|
/// them across the full bar geometry (DrawMode=Normal) and clips the fill to the fraction.
|
|
/// (retail: gmToolbarUI::HandleSelectionChanged :198635, UIElement_Meter::Initialize :123328)
|
|
/// <code>
|
|
/// meter (Type 7) [DirectState "" → back-track sprite, e.g. 0x0600193E]
|
|
/// └── fill container (Type 3) [DirectState "" → fill sprite, e.g. 0x0600193F]
|
|
/// </code>
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <see cref="UiMeter.Fill"/> and <see cref="UiMeter.Label"/> are NOT set here.
|
|
/// They are bound to the live stat providers by the controller (VitalsController /
|
|
/// SelectedObjectController).
|
|
/// </para>
|
|
/// </summary>
|
|
private static UiMeter BuildMeter(ElementInfo info,
|
|
Func<uint, (uint, int, int)> resolve, UiDatFont? datFont,
|
|
Func<UiStringInfoValue, string?>? stringResolve = null)
|
|
{
|
|
var m = new UiMeter
|
|
{
|
|
ElementId = info.Id,
|
|
SpriteResolve = resolve,
|
|
DatFont = datFont,
|
|
// Outline 0x21 from the meter element (round-5 review S2).
|
|
Outline = info.Outline,
|
|
};
|
|
if (info.OutlineColor.HasValue)
|
|
m.OutlineColor = info.OutlineColor.Value;
|
|
|
|
// The two 3-slice containers are Type-3 children of the meter element.
|
|
// ReadOrder determines draw order: the back track has a LOWER ReadOrder
|
|
// (drawn first, behind the fill), the front has a HIGHER ReadOrder (on top).
|
|
var containers = info.Children
|
|
.Where(c => c.Type == 3)
|
|
.OrderBy(c => c.ReadOrder)
|
|
.ToList();
|
|
|
|
if (containers.Count >= 2
|
|
&& HasThreeSliceShape(containers[0])
|
|
&& HasThreeSliceShape(containers[1]))
|
|
{
|
|
// Vitals 3-slice shape: two Type-3 containers each holding 3 grandchild images
|
|
// (left-cap / center-tile / right-cap). Back is the lower ReadOrder; front is higher.
|
|
var (bl, bt, br) = SliceIds(containers[0]);
|
|
m.BackLeft = bl;
|
|
m.BackTile = bt;
|
|
m.BackRight = br;
|
|
|
|
var (fl, ft, fr) = SliceIds(containers[1]);
|
|
m.FrontLeft = fl;
|
|
m.FrontTile = ft;
|
|
m.FrontRight = fr;
|
|
}
|
|
else if (containers.Count == 1 && containers[0].StateMedia.ContainsKey(""))
|
|
{
|
|
// Single-image shape used by the toolbar selected-object meters
|
|
// (health 0x100001A1, mana 0x100001A2).
|
|
// - The back-track sprite lives on the meter ELEMENT's own DirectState ("" key of
|
|
// info.StateMedia) — not on any grandchild image. e.g. health back = 0x0600193E.
|
|
// - The fill sprite lives on the single Type-3 child's own DirectState ("" key of
|
|
// containers[0].StateMedia). e.g. health fill = 0x0600193F.
|
|
// The fill child has NO image grandchildren, so SliceIds would return all-zero —
|
|
// read the container's StateMedia directly instead.
|
|
//
|
|
// These go in the TILE slot (not the left-cap slot): the sprites are DrawMode=Normal,
|
|
// which retail renders as "tile at native width to fill the full element geometry"
|
|
// (format doc §6; the generic UiDatElement.OnDraw Normal path; UIElement_Meter::
|
|
// DrawChildren :123574 clips the child's FULL 140px geometry box to the fill fraction).
|
|
// With the sprite on BackLeft instead, UiMeter.DrawHBar would clamp the cap to the
|
|
// sprite's NATIVE width (capL = min(nativeW, 140)) — leaving a right-side gap and
|
|
// mapping the fill fraction to native width when nativeW < 140. The tile slot makes
|
|
// midW = full bar width, so the back tiles across all 140px and the front clips to
|
|
// 140*fraction correctly for any native sprite width (left/right caps unused = 0).
|
|
// (retail: gmToolbarUI::HandleSelectionChanged :198635 / UIElement_Meter::DrawChildren :123574)
|
|
m.BackLeft = 0;
|
|
m.BackTile = info.StateMedia.TryGetValue("", out var bm) ? bm.File : 0u;
|
|
m.BackRight = 0;
|
|
|
|
m.FrontLeft = 0;
|
|
m.FrontTile = containers[0].StateMedia.TryGetValue("", out var fm) ? fm.File : 0u;
|
|
m.FrontRight = 0;
|
|
}
|
|
else if (containers.Any(HasStatefulFill))
|
|
{
|
|
// Stateful single-image shape used by gmPowerbarUI (LayoutDesc
|
|
// 0x21000072). The meter's DirectState is the empty track. One
|
|
// Type-3 child supplies named Jump/Melee/Missile/DDD state media;
|
|
// another carries the optional recklessness range as DirectState.
|
|
// gmPowerbarUI::PostInit @ 0x004DA4E0 hides that range, so it is not
|
|
// promoted into a meter slice here.
|
|
m.BackLeft = 0;
|
|
m.BackTile = info.StateMedia.TryGetValue("", out var track) ? track.File : 0u;
|
|
m.BackRight = 0;
|
|
m.FrontLeft = 0;
|
|
m.FrontTile = 0;
|
|
m.FrontRight = 0;
|
|
|
|
foreach (ElementInfo container in containers)
|
|
{
|
|
foreach (var (stateId, state) in container.States)
|
|
{
|
|
if (stateId == UiStateInfo.DirectStateId
|
|
|| !container.StateMedia.TryGetValue(state.Name, out var media))
|
|
continue;
|
|
m.ConfigureStateFill(stateId, media.File);
|
|
}
|
|
}
|
|
|
|
// The absorbed Type-12 caption child (gmPowerbarUI's 0x10000035)
|
|
// authors the per-mode caption on its own states — JumpMode
|
|
// 'Height', MeleeMode 'Power', MissileMode 'Accuracy' (HJustify
|
|
// Center over the full bar, matching UiMeter's centered label
|
|
// draw). Retail shows it through the meter's PassToChildren state
|
|
// cascade; the absorbed equivalent is a state-label table
|
|
// consulted by TrySetRetailState.
|
|
foreach (ElementInfo textChild in info.Children.Where(static c => c.Type == 12))
|
|
{
|
|
foreach (var (stateId, state) in textChild.States)
|
|
{
|
|
if (stateId == UiStateInfo.DirectStateId
|
|
|| !state.Properties.Values.TryGetValue(0x17u, out var caption)
|
|
|| caption.Kind != UiPropertyKind.StringInfo)
|
|
continue;
|
|
if (stringResolve?.Invoke(caption.StringInfoValue) is { Length: > 0 } text)
|
|
m.ConfigureStateLabel(stateId, text);
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine($"[D.2b] meter 0x{info.Id:X8}: {containers.Count} Type-3 containers but no recognized 3-slice, direct-fill, or stateful-fill shape — bar may render as solid-color fallback.");
|
|
}
|
|
|
|
return m;
|
|
}
|
|
|
|
private static bool HasThreeSliceShape(ElementInfo container)
|
|
=> container.Children.Count(c =>
|
|
c.StateMedia.TryGetValue("", out var media) && media.File != 0) >= 3;
|
|
|
|
private static bool HasStatefulFill(ElementInfo container)
|
|
=> container.States.Any(pair =>
|
|
pair.Key != UiStateInfo.DirectStateId
|
|
&& container.StateMedia.TryGetValue(pair.Value.Name, out var media)
|
|
&& media.File != 0);
|
|
|
|
/// <summary>
|
|
/// Returns the (left, tile, right) sprite ids for a 3-slice container,
|
|
/// extracting them from the container's image children that carry a DirectState
|
|
/// ("" key) with a non-zero file id, ordered left-to-right by their X position.
|
|
///
|
|
/// <para>
|
|
/// Children that carry ONLY named states (e.g. the expand-detail overlay with
|
|
/// "ShowDetail"/"HideDetail" entries but no "" key) are excluded automatically
|
|
/// because <see cref="Dictionary{TKey,TValue}.TryGetValue"/> for "" returns
|
|
/// false.
|
|
/// </para>
|
|
/// </summary>
|
|
private static (uint left, uint tile, uint right) SliceIds(ElementInfo container)
|
|
{
|
|
// Only children that have a non-zero DirectState image are slice candidates.
|
|
// The expand-detail overlay has NO DirectState entry, so it's excluded here.
|
|
// Project the File during filtering to avoid a second TryGetValue lookup.
|
|
// Stable sort: on an X tie, original Children insertion order (dat key-sort order) wins.
|
|
var slices = container.Children
|
|
.Where(c => c.StateMedia.TryGetValue("", out var med) && med.File != 0)
|
|
.Select(c => (c.X, File: c.StateMedia[""].File))
|
|
.OrderBy(t => t.X)
|
|
.ToList();
|
|
|
|
uint left = slices.Count > 0 ? slices[0].File : 0u;
|
|
uint tile = slices.Count > 1 ? slices[1].File : 0u;
|
|
uint right = slices.Count > 2 ? slices[2].File : 0u;
|
|
|
|
return (left, tile, right);
|
|
}
|
|
|
|
// ── Text ─────────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Type-12 UIElement_Text: an editable field or colored-line text view,
|
|
/// selected from the canonical property bag. The element's
|
|
/// own Direct/Normal media (if any) becomes the background sprite, drawn under the text —
|
|
/// so a Type-12 element that previously rendered via UiDatElement keeps its sprite. Lines
|
|
/// are bound later by the controller (LinesProvider). An unbound UiText draws nothing
|
|
/// because <see cref="UiText.BackgroundColor"/> defaults to transparent.
|
|
///
|
|
/// <para>
|
|
/// Justification from the dat (<see cref="ElementInfo.HJustify"/> /
|
|
/// <see cref="ElementInfo.VJustify"/>) is applied here at build time so that controllers
|
|
/// that subsequently call <see cref="UiText.Centered"/> / <see cref="UiText.RightAligned"/>
|
|
/// on dat-origin elements can be simplified. Controllers that <em>explicitly</em> set those
|
|
/// properties after <see cref="ImportedLayout.FindElement"/> still override the build-time
|
|
/// defaults — the build-time value is just the starting point, not a lock.
|
|
/// </para>
|
|
/// </summary>
|
|
/// <param name="elementFont">The font to seed on the widget. When a font resolver was
|
|
/// provided and the element's FontDid resolved successfully, this is that element-specific
|
|
/// font; otherwise it is the shared global fallback. Controllers that call
|
|
/// <see cref="ImportedLayout.FindElement"/> and set <see cref="UiText.DatFont"/> afterward
|
|
/// still override this — the build-time value is just the starting point.</param>
|
|
private static UiElement BuildText(ElementInfo info, Func<uint, (uint, int, int)> resolve,
|
|
UiDatFont? elementFont = null,
|
|
Func<UiStringInfoValue, string?>? stringResolve = null)
|
|
{
|
|
uint bg = info.StateMedia.TryGetValue(
|
|
!string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName
|
|
: info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m)
|
|
? m.File : 0u;
|
|
|
|
bool editable = info.TryGetEffectiveBool(0x16u, out var editableValue)
|
|
&& editableValue;
|
|
bool selectable = info.TryGetEffectiveBool(0x27u, out var selectableValue)
|
|
&& selectableValue;
|
|
bool oneLine = info.TryGetEffectiveBool(0x20u, out var oneLineValue)
|
|
&& oneLineValue;
|
|
|
|
if (editable)
|
|
{
|
|
uint focusSprite = info.StateMedia.TryGetValue("Normal_focussed", out var focus)
|
|
? focus.File
|
|
: 0u;
|
|
var field = new UiField
|
|
{
|
|
ElementId = info.Id,
|
|
DatFont = elementFont,
|
|
SpriteResolve = resolve,
|
|
BackgroundSprite = bg,
|
|
FocusFieldSprite = focusSprite,
|
|
Selectable = selectable,
|
|
OneLine = oneLine,
|
|
Centered = info.HJustify == HJustify.Center,
|
|
RightAligned = info.HJustify == HJustify.Right,
|
|
// Outline 0x21 from the field element (round-5 review S2).
|
|
Outline = info.Outline,
|
|
};
|
|
if (info.TryGetEffectiveInteger(0x1Eu, out int maxCharacters))
|
|
field.MaxCharacters = maxCharacters;
|
|
if (info.FontColor.HasValue)
|
|
field.TextColor = info.FontColor.Value;
|
|
if (info.OutlineColor.HasValue)
|
|
field.OutlineColor = info.OutlineColor.Value;
|
|
return field;
|
|
}
|
|
|
|
// Apply horizontal + vertical justification from the dat at build time.
|
|
// Controllers that call FindElement and set Centered/RightAligned/VerticalJustify
|
|
// afterward will override these — this is only the dat-driven default.
|
|
bool centered = info.HJustify == HJustify.Center;
|
|
bool rightAligned = info.HJustify == HJustify.Right;
|
|
var vJustify = info.VJustify switch
|
|
{
|
|
VJustify.Top => VJustify.Top,
|
|
VJustify.Bottom => VJustify.Bottom,
|
|
_ => VJustify.Center,
|
|
};
|
|
|
|
var t = new UiText
|
|
{
|
|
ElementId = info.Id,
|
|
BackgroundSprite = bg,
|
|
SpriteResolve = resolve,
|
|
Centered = centered,
|
|
RightAligned = rightAligned,
|
|
VerticalJustify = vJustify,
|
|
OneLine = oneLine,
|
|
Selectable = selectable,
|
|
// Seed the dat-driven font. When a font resolver was supplied and the element
|
|
// carries a non-zero FontDid, elementFont is the element-specific dat font; otherwise
|
|
// it is the shared global fallback. Controllers that call FindElement and explicitly
|
|
// set DatFont afterward STILL override this (backward-compat guarantee).
|
|
DatFont = elementFont,
|
|
FontColorPalette = ElementReader.ReadEffectiveColorPalette(
|
|
info,
|
|
0x1Bu),
|
|
// Outline from dat property 0x21 (BoolBaseProperty). Default false — matches
|
|
// ElementInfo.Outline's own default, so this is a no-op for the ~99% of text
|
|
// elements that don't author it.
|
|
Outline = info.Outline,
|
|
};
|
|
t.ConfigureDatState(info);
|
|
|
|
// Font color from dat property 0x1B (ColorBaseProperty).
|
|
// When present, seed DefaultColor so controllers that read it don't have to hard-code colors.
|
|
// Controllers that supply explicit per-line colors via LinesProvider still win — this is only
|
|
// the build-time default.
|
|
if (info.FontColor.HasValue)
|
|
t.DefaultColor = info.FontColor.Value;
|
|
|
|
// Outline color from dat property 0x22 (ColorBaseProperty). Only 9 elements in the
|
|
// whole DAT set author a non-black value; when absent, UiText's own ctor default
|
|
// (black, matching retail's m_curOutlineColor default) already applies.
|
|
if (info.OutlineColor.HasValue)
|
|
t.OutlineColor = info.OutlineColor.Value;
|
|
|
|
if (ResolveAuthoredString(info, stringResolve) is { Length: > 0 } authored)
|
|
{
|
|
// 2026-08-13 social gate: authored strings can carry embedded
|
|
// newlines (the fellowship empty-state is three sentences over
|
|
// '\n's). Gate round 2: the DAT stores the LITERAL two-character
|
|
// escape "\n" (0x5C 0x6E — probe-verified: the dump printed
|
|
// backslash-n, not a line break), so normalize the escape first.
|
|
// Gate round 3: retail additionally WORD-WRAPS each authored line
|
|
// within the element extent (its GlyphList draw — the same wrap
|
|
// the confirmation dialog view already uses), so a multiline
|
|
// authored block re-wraps to the widget's live width instead of
|
|
// clipping at the edge. Single-line authored labels keep the
|
|
// one-run shape they have always had (they are authored to fit;
|
|
// re-wrapping them is a client-wide behavior change no gate has
|
|
// asked for). Providers re-read DefaultColor/width/font per call
|
|
// (NOT captured eagerly) so state-driven changes keep tracking.
|
|
string normalized = authored
|
|
.Replace("\\n", "\n")
|
|
.Replace("\r", string.Empty);
|
|
if (normalized.Contains('\n'))
|
|
{
|
|
float cachedWidth = float.NaN;
|
|
UiDatFont? cachedFont = null;
|
|
System.Numerics.Vector4 cachedColor = default;
|
|
UiText.Line[]? cachedLines = null;
|
|
t.LinesProvider = () =>
|
|
{
|
|
if (cachedLines is null
|
|
|| cachedWidth != t.Width
|
|
|| !ReferenceEquals(cachedFont, t.DatFont)
|
|
|| cachedColor != t.DefaultColor)
|
|
{
|
|
cachedWidth = t.Width;
|
|
cachedFont = t.DatFont;
|
|
cachedColor = t.DefaultColor;
|
|
float maximumWidth = Math.Max(1f, t.Width - 2f * t.Padding);
|
|
Func<string, float> measure = t.DatFont is { } font
|
|
? font.MeasureWidth
|
|
: static value => value.Length * 8f;
|
|
cachedLines = [.. UiText
|
|
.WrapWords(normalized, measure, maximumWidth)
|
|
.Select(line => new UiText.Line(line, t.DefaultColor))];
|
|
}
|
|
return cachedLines;
|
|
};
|
|
}
|
|
else
|
|
{
|
|
t.LinesProvider = () =>
|
|
[new UiText.Line(normalized, t.DefaultColor)];
|
|
}
|
|
}
|
|
|
|
return t;
|
|
}
|
|
|
|
private static UiButton BuildButton(
|
|
ElementInfo info,
|
|
Func<uint, (uint, int, int)> resolve,
|
|
UiDatFont? elementFont,
|
|
Func<uint, UiDatFont?>? fontResolve,
|
|
Func<UiStringInfoValue, string?>? stringResolve)
|
|
{
|
|
// UIElement_Button propagates its retail state into authored children.
|
|
// Spellbook school/level filters are Type-1 buttons with no parent media:
|
|
// their 13x13 child carries Normal/Highlight art. Keep that child as the
|
|
// face of this retained leaf instead of consuming and losing it.
|
|
ElementInfo[] authoredFaces = info.StateMedia.Count == 0
|
|
? FindStatefulFaceChildren(info)
|
|
: [];
|
|
ElementInfo? face = authoredFaces.Length == 1 ? authoredFaces[0] : null;
|
|
IReadOnlyList<ElementInfo>? faceSegments = authoredFaces.Length > 1
|
|
? authoredFaces
|
|
: null;
|
|
|
|
string? label = ResolveAuthoredString(info, stringResolve);
|
|
ElementInfo labelInfo = info;
|
|
if (label is null)
|
|
{
|
|
// Normal retail buttons such as the spellbook Delete control keep
|
|
// their caption in a full-size UIElement_Text child. UiButton is a
|
|
// retained leaf, so lift that authored text/font/color onto the leaf.
|
|
foreach (ElementInfo child in info.Children.Where(child => child.Type == 12u))
|
|
{
|
|
label = ResolveAuthoredString(child, stringResolve);
|
|
if (label is null) continue;
|
|
labelInfo = child;
|
|
break;
|
|
}
|
|
}
|
|
|
|
UiDatFont? labelFont = elementFont;
|
|
if (labelInfo.FontDid != 0u && fontResolve is not null)
|
|
labelFont = fontResolve(labelInfo.FontDid) ?? elementFont;
|
|
|
|
var button = new UiButton(info, resolve, face, faceSegments)
|
|
{
|
|
Label = label,
|
|
LabelFont = labelFont,
|
|
LabelColor = labelInfo.FontColor ?? info.FontColor
|
|
?? System.Numerics.Vector4.One,
|
|
// Outline 0x21 / OutlineColor 0x22 follow the same lift chain as the label
|
|
// and its color: the label-bearing Text child's authored value first, the
|
|
// button's own second (round-5 review S2).
|
|
Outline = labelInfo.Outline || info.Outline,
|
|
};
|
|
if ((labelInfo.OutlineColor ?? info.OutlineColor) is { } buttonOutlineColor)
|
|
button.OutlineColor = buttonOutlineColor;
|
|
|
|
if (face is not null)
|
|
{
|
|
button.FaceLeft = face.X;
|
|
button.FaceTop = face.Y;
|
|
button.FaceWidth = face.Width;
|
|
button.FaceHeight = face.Height;
|
|
button.LabelAlign = UiButton.LabelAlignment.Left;
|
|
button.LabelOffsetX = face.X + face.Width + 4f;
|
|
}
|
|
else if (!ReferenceEquals(labelInfo, info) && labelInfo.HJustify == HJustify.Left)
|
|
{
|
|
button.LabelAlign = UiButton.LabelAlignment.Left;
|
|
button.LabelOffsetX = labelInfo.X;
|
|
}
|
|
|
|
return button;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail UIOption_Checkbox is a UIElement_Button whose visible face is its
|
|
/// authored indicator child. Its label lives on the option object rather than
|
|
/// in a child UIElement_Text.
|
|
/// </summary>
|
|
private static UiButton BuildCheckbox(
|
|
ElementInfo info,
|
|
Func<uint, (uint, int, int)> resolve,
|
|
UiDatFont? elementFont,
|
|
Func<uint, UiDatFont?>? fontResolve,
|
|
Func<UiStringInfoValue, string?>? stringResolve)
|
|
{
|
|
ElementInfo? indicator = FindStatefulFaceChild(info);
|
|
var button = new UiButton(info, resolve, indicator)
|
|
{
|
|
Label = ResolveAuthoredString(info, stringResolve),
|
|
LabelFont = info.FontDid != 0u && fontResolve is not null
|
|
? fontResolve(info.FontDid) ?? elementFont
|
|
: elementFont,
|
|
LabelColor = info.FontColor ?? System.Numerics.Vector4.One,
|
|
LabelAlign = UiButton.LabelAlignment.Left,
|
|
// Outline 0x21 from the checkbox element itself (round-5 review S2).
|
|
Outline = info.Outline,
|
|
};
|
|
if (info.OutlineColor.HasValue)
|
|
button.OutlineColor = info.OutlineColor.Value;
|
|
|
|
if (indicator is not null)
|
|
{
|
|
button.FaceLeft = indicator.X;
|
|
button.FaceTop = indicator.Y;
|
|
button.FaceWidth = indicator.Width;
|
|
button.FaceHeight = indicator.Height;
|
|
button.LabelOffsetX = indicator.X + indicator.Width + 4f;
|
|
}
|
|
|
|
return button;
|
|
}
|
|
|
|
private static ElementInfo? FindStatefulFaceChild(ElementInfo info)
|
|
=> FindStatefulFaceChildren(info).FirstOrDefault();
|
|
|
|
private static ElementInfo[] FindStatefulFaceChildren(ElementInfo info)
|
|
=> info.Children.Where(child =>
|
|
child.StateMedia.Count != 0
|
|
&& child.StateMedia.Keys.Any(childState =>
|
|
info.States.Values.Any(parentState =>
|
|
string.Equals(parentState.Name, childState, StringComparison.Ordinal))))
|
|
.OrderBy(child => child.ReadOrder)
|
|
.ToArray();
|
|
|
|
private static string? ResolveAuthoredString(
|
|
ElementInfo info,
|
|
Func<UiStringInfoValue, string?>? stringResolve)
|
|
{
|
|
if (stringResolve is null
|
|
|| !info.TryGetEffectiveProperty(0x17u, out var property)
|
|
|| property.Kind != UiPropertyKind.StringInfo)
|
|
return null;
|
|
return stringResolve(property.StringInfoValue);
|
|
}
|
|
}
|