Applies all 11 items from the Opus dual-lens review of989f6652(0 blockers, 7 SHOULD-FIX, 4 NOTE): - S2: UiElement.DrawSelfAndChildren now pushes the ambient clip right after PushAlpha and wraps OnDraw + the children walk + OnDrawAfterChildren in ONE block — the literal UIRegion::DrawHere @0x0069FA30 shape, which clips an element's OWN DrawSelf too, not just its children (UIElement_Text::DrawSelf @0x00467AA0 locks glyph blits to its own clipped surface rect; UIRegion::DrawSelf @0x0069F1A0 blits per clip rect). Deleted the two now-redundant ad-hoc self-clips this supersedes: UiText.DrawText and UiField.DrawMultiLine both pushed their own (0,0,Width,Height) — exactly what the new ambient clip already provides one level up. Kept UiButton.DrawBlockLabel's clip: it clips to LabelBox/ValueBox, an authored INNER sub-rect that can be smaller than and offset from the button's own full rect — a genuine narrower viewport, not a redundant duplicate. - S3: deleted UiItemList's `ClipsChildren => CellWidth > 0f` override — correct under the old opt-in-false default, inverted under the new default-true (an unconfigured list would stop clipping instead of clipping like everything else). - S4: pinned the escaped-popup input path end to end. New UiAncestorClipTests test mounts a menu inside a short window on a real UiRoot, opens it, and proves a click in the escaped popup region reaches the menu through UiRoot.PopupHit (a plain top-down walk is proven to reject the same point first). UiRoot.WantsMouse now also checks PopupHit — it previously only checked Captured/ HitTestTopDown, so a game action could fire underneath an open dropdown's escaped region. OnMouseDown/OnScroll already routed through PopupHit first (#374); unchanged. - S5: strengthened the Titles-divider regression test's positive half. The old assertion only checked SOME quad's Y fell in a band — vacuously true given other same-band content. Now asserts the divider's exact rect (X and Y), then diffs against the same rect with the divider hidden (Visible=false) to prove the quad was actually attributable to it. - S1: added UiWindowDrawCaptureSweepTests — Character/Chat/Vendor/ Options mounted through their real production Bind entry points with a non-zero sprite resolver, drawn via RecordingGpuDevice, asserting a per-window vertex floor (~40-45% of this session's observed baseline: Character 588, Chat 162, Vendor 54, Options 240) plus one key sprite id read LIVE off the bound controller/element (never hardcoded). Character's key sprite (RetailChromeSprites. TopEdge) specifically exercises OnDrawAfterChildren, the exact path S2's caution note flagged. Inventory/Paperdoll/social/map-house skipped — no single fixture-driven top-level Bind entry point. - S6: added the CT-GF1 subsection to the campaign plan's ledger (989f6652+ this fix round; CT7 re-gate still owed). - S7: UiRenderContext.PushClipUnbounded now resets to the CANVAS rect (0,0,ScreenSize), not null — retail's own popup region is SCREEN-clipped (UIElement_Menu::MakePopup spawns a top-level region bounded by the screen), not truly unbounded. AD-113 amended. - N1: UiRoot overrides ClipsChildren => false — the root's own region IS the screen (the viewport already scissors it), so this is a safety net against a momentarily zero-sized root silently blanking the whole UI tree under the new ancestor-clip default. - N2: added the empty-clip subtree cull (retail's var_24 gate @0x0069FB8E) to DrawSelfAndChildren only — DrawOverlays is a wholly separate traversal untouched by this change. New test proves a menu inside a fully-clipped (zero-width) window still draws its open popup via the overlay pass while the main pass draws nothing. - N3: CT7 script §5 now names the collapsed-toolbar check and the four highest-overflow windows (combat/vitals bar, Options bottom-button row, map/house page, floaty chat) as explicit eyeball items for the re-gate. - N4: verification below covers both the working tree and the clean committed tree. Decomp anchors: UIRegion::DrawHere @0x0069FA30 (var_24 gate @0x0069FB8E); UIElement_Text::DrawSelf @0x00467AA0 (self-clip); UIRegion::DrawSelf @0x0069F1A0; UIElement_Menu::MakePopup (screen- clipped popup region). Verification (both runs green, --filter "Lane!=InstalledDat& Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing& Lane!=Windows&Lane!=Linux&Lane!=SystemFont&Purpose!=Diagnostic& Status!=KnownFailure"): full Release solution build green; working tree 14,900+ tests across every project (one LandblockPresentation PipelineTests flake reproduced ONLY under full-solution parallel load, passes standalone and on rerun — unrelated to this change, streaming domain); InstalledDat lane green (ACDREAM_RUN_INSTALLED_DAT _TESTS=1, Status!=KnownFailure, 205+34+3+172 App/Content/Bake/Core tests). Clean committed tree (git stash push -u the uncommitted owner probe + docs files, rerun, stash pop) reported in the session summary. src/AcDream.App/UI/UiRoot.cs carries an unrelated, pre-existing uncommitted owner probe (ACDREAM_PROBE_UI_HOVER) — staged selectively (git add -p) so only this commit's own two hunks (ClipsChildren override, WantsMouse) landed; the probe hunk is untouched and stays uncommitted, same as before this fix round. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
924 lines
42 KiB
C#
924 lines
42 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
|
|
namespace AcDream.App.UI;
|
|
|
|
/// <summary>Which parent edges a child keeps a fixed margin to on resize.
|
|
/// Left+Right ⇒ width stretches; Top+Bottom ⇒ height stretches.</summary>
|
|
[System.Flags]
|
|
public enum AnchorEdges { None = 0, Left = 1, Top = 2, Right = 4, Bottom = 8 }
|
|
|
|
/// <summary>Retail dat cursor media attached to a UI state.</summary>
|
|
public readonly record struct UiCursorMedia(uint File, int HotspotX, int HotspotY)
|
|
{
|
|
public bool IsValid => File != 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Base class for every UI widget in the retained-mode tree.
|
|
///
|
|
/// Design notes:
|
|
/// - Retail AC delegates widget semantics to the external
|
|
/// <c>keystone.dll</c> library (see
|
|
/// <c>docs/research/retail-ui/02-class-hierarchy.md</c> — there is no
|
|
/// widget hierarchy inside <c>acclient.exe</c> itself). We implement
|
|
/// our own retained-mode toolkit here, matching the <i>behavior</i>
|
|
/// described in the decompile without trying to byte-match Keystone's
|
|
/// internal class layout.
|
|
/// - Events use the retail-faithful <see cref="UiEvent"/> struct and
|
|
/// the <see cref="UiEventType"/> constants so that hand-ported panel
|
|
/// code can use the same magic numbers the decompiled C uses
|
|
/// (e.g. <c>if (e.Type == 0x15) ...</c> for drag-begin).
|
|
/// - Hit-testing is children-first (topmost wins) with Z-order tie
|
|
/// breaking; drawing is back-to-front so later children appear on top.
|
|
/// - Coordinates are in <b>screen pixels</b>, origin top-left.
|
|
/// <see cref="Bounds"/> is in the parent's local coordinate space.
|
|
/// </summary>
|
|
public abstract class UiElement
|
|
{
|
|
// ── Identity ─────────────────────────────────────────────────────────
|
|
/// <summary>
|
|
/// Unique 32-bit event ID. Retail uses the range <c>0x10000000+</c>
|
|
/// for custom app events (see
|
|
/// <c>docs/research/retail-ui/04-input-events.md §3</c>). Assigned
|
|
/// by <see cref="UiRoot"/> when the element is added to the tree.
|
|
/// </summary>
|
|
public uint EventId { get; internal set; }
|
|
|
|
/// <summary>
|
|
/// Dat LayoutDesc element id for widgets imported from retail UI data.
|
|
/// Zero for runtime-created helper widgets. This is intentionally separate
|
|
/// from <see cref="EventId"/>: event ids are runtime-local, while this id is
|
|
/// stable across retail layout dumps, decomp references, and UI probes.
|
|
/// </summary>
|
|
public uint DatElementId { get; internal set; }
|
|
|
|
/// <summary>Human-readable name for debugging / FindByName.</summary>
|
|
public string? Name { get; init; }
|
|
|
|
/// <summary>
|
|
/// GF-13 (Campaign CC gate round 1, Batch A): mirrors
|
|
/// <c>ElementInfo.Invisible</c> (dat property <c>0x3B</c>) — a PURE DATA
|
|
/// PASSTHROUGH set by <c>LayoutImporter.BuildWidget</c> at construction.
|
|
/// The shared importer does NOT act on this flag (1,083 elements author
|
|
/// it client-wide, docs/ISSUES.md #408); it exists only so a screen that
|
|
/// owns its own mounted subtree can honor it explicitly, the way
|
|
/// <c>CharacterCreationUiController</c> does for the chargen screen
|
|
/// (register AP-230). Reading this never changes <see cref="Visible"/> by
|
|
/// itself.
|
|
/// </summary>
|
|
public bool AuthoredInvisible { get; internal set; }
|
|
|
|
/// <summary>
|
|
/// #409 (client-wide retail tooltip system): mirrors
|
|
/// <c>ElementInfo.TooltipEnabled</c> (dat property <c>0x4B</c>) — a
|
|
/// PURE DATA PASSTHROUGH set by <c>LayoutImporter.BuildWidget</c>.
|
|
/// Gates whether <see cref="RetailTooltipPresenter"/> may show a
|
|
/// tooltip for this widget at all (retail's per-element
|
|
/// <c>UIRegion::SetTooltipOn</c> bit, checked by
|
|
/// <c>UIElement::MouseHover @0x00462520</c> alongside the global
|
|
/// enable preference). See <see cref="AcDream.App.UI.Layout.ElementInfo.TooltipEnabled"/>.
|
|
/// </summary>
|
|
public bool AuthoredTooltipEnabled { get; internal set; }
|
|
|
|
/// <summary>
|
|
/// #409: mirrors <c>ElementInfo.TooltipText</c> (dat property
|
|
/// <c>0x49</c>), already resolved to a display string through
|
|
/// <c>DatStringResolver</c> and escape-normalized at import time —
|
|
/// the same treatment every other authored <c>StringInfo</c> caption
|
|
/// gets. Null when the element authors no tooltip text.
|
|
/// </summary>
|
|
public string? AuthoredTooltipText { get; internal set; }
|
|
|
|
/// <summary>
|
|
/// #409: mirrors <c>ElementInfo.TooltipRootElementId</c> (dat property
|
|
/// <c>0x47</c>) — the element-desc id WITHIN
|
|
/// <see cref="AuthoredTooltipLayoutDid"/>'s LayoutDesc to instantiate
|
|
/// as the tooltip popup's root. Zero when absent.
|
|
/// </summary>
|
|
public uint AuthoredTooltipRootElementId { get; internal set; }
|
|
|
|
/// <summary>
|
|
/// #409: mirrors <c>ElementInfo.TooltipLayoutDid</c> (dat property
|
|
/// <c>0x48</c>) — the tooltip popup LayoutDesc DID (retail authors
|
|
/// <c>0x21000041</c> on every tooltip-bearing element). Zero when
|
|
/// absent.
|
|
/// </summary>
|
|
public uint AuthoredTooltipLayoutDid { get; internal set; }
|
|
|
|
/// <summary>
|
|
/// #409 (live-failure round): the DID of the <c>LayoutDesc</c> this widget
|
|
/// was imported from, stamped by <c>LayoutImporter.Import</c>'s dat shell.
|
|
/// Retail's <c>UIElement::StartTooltipAtMouse @0x00460D70</c> falls back to
|
|
/// exactly this — <c>this->m_layout->m_DID</c> (<c>@0x00460E7E</c>) — when
|
|
/// the hovered element authors a tooltip popup ROOT (<c>P0x47</c>) but no
|
|
/// popup LAYOUT (<c>P0x48</c>), i.e. the popup root lives in the element's
|
|
/// own layout. Zero when the widget came through the pure
|
|
/// <c>LayoutImporter.Build</c> layer (which has no dat context) or was
|
|
/// constructed directly; the presenter then behaves exactly as it did
|
|
/// before this field existed.
|
|
/// </summary>
|
|
public uint SourceLayoutDid { get; internal set; }
|
|
|
|
/// <summary>
|
|
/// Optional game object represented by this retained widget for retail's
|
|
/// global found-object cursor. Most widgets leave this unset. The toolbar
|
|
/// inventory/backpack button supplies the player object: retail treats that
|
|
/// button as the self/backpack target (the same target used by its click and
|
|
/// drop paths), so hovering it selects the Found cursor family even though
|
|
/// the authored element is a Button rather than a UIItem.
|
|
/// </summary>
|
|
public Func<uint>? FoundObjectGuidProvider { get; set; }
|
|
|
|
/// <summary>
|
|
/// #409: mirrors <c>ElementInfo.TooltipTextChildElementId</c> (dat
|
|
/// property <c>0x4A</c>). Meaningful only when read off a tooltip
|
|
/// POPUP's own instantiated ROOT widget — see the
|
|
/// <c>ElementInfo</c> field's own doc for why retail reads this off
|
|
/// the popup, not the hovering trigger element.
|
|
/// </summary>
|
|
public uint AuthoredTooltipTextChildElementId { get; internal set; }
|
|
|
|
/// <summary>
|
|
/// #409: mirrors <c>ElementInfo.TooltipDelaySeconds</c> (dat property
|
|
/// <c>0x50</c>) — a per-element hover-dwell override in seconds, used
|
|
/// by <see cref="UiRoot"/>'s tooltip timer in place of the global
|
|
/// <see cref="UiRoot.TooltipDelayMs"/> when present. Null = no override.
|
|
/// </summary>
|
|
public float? AuthoredTooltipDelaySeconds { get; internal set; }
|
|
|
|
/// <summary>
|
|
/// #409 F8: mirrors <c>ElementInfo.MaxWidth</c>/<c>MinWidth</c> (dat
|
|
/// properties <c>0x3D</c>/<c>0x3F</c>) — the <c>UIElement::ResizeTo
|
|
/// @0x00463C30</c> auto-resize width clamp. Null = no authored
|
|
/// override on that side.
|
|
/// </summary>
|
|
public int? AuthoredResizeMaxWidth { get; internal set; }
|
|
public int? AuthoredResizeMinWidth { get; internal set; }
|
|
|
|
/// <summary>
|
|
/// #409 F8: mirrors <c>ElementInfo.MaxHeight</c>/<c>MinHeight</c> (dat
|
|
/// properties <c>0x3C</c>/<c>0x3E</c>) — the height side of the same
|
|
/// <c>UIElement::ResizeTo @0x00463C30</c> clamp. Null = no authored
|
|
/// override on that side.
|
|
/// </summary>
|
|
public int? AuthoredResizeMaxHeight { get; internal set; }
|
|
public int? AuthoredResizeMinHeight { get; internal set; }
|
|
|
|
private readonly Dictionary<string, UiCursorMedia> _stateCursors = new();
|
|
|
|
/// <summary>Retail MediaDescCursor entries keyed by UIStateId.ToString(), or "" for DirectState.</summary>
|
|
public IReadOnlyDictionary<string, UiCursorMedia> StateCursors => _stateCursors;
|
|
|
|
/// <summary>Active state name used for cursor media. Stateful dat widgets override this.</summary>
|
|
public virtual string ActiveCursorStateName => "";
|
|
|
|
public void SetStateCursors(IReadOnlyDictionary<string, UiCursorMedia> cursors)
|
|
{
|
|
_stateCursors.Clear();
|
|
foreach (var kv in cursors)
|
|
{
|
|
if (kv.Value.IsValid)
|
|
_stateCursors[kv.Key] = kv.Value;
|
|
}
|
|
}
|
|
|
|
public UiCursorMedia ActiveCursor()
|
|
=> CursorForState(ActiveCursorStateName, allowFallback: true);
|
|
|
|
public UiCursorMedia CursorForState(string stateName, bool allowFallback = true)
|
|
{
|
|
if (!string.IsNullOrEmpty(stateName)
|
|
&& _stateCursors.TryGetValue(stateName, out var named)
|
|
&& named.IsValid)
|
|
return named;
|
|
|
|
if (!allowFallback)
|
|
return default;
|
|
|
|
if (_stateCursors.TryGetValue("", out var direct) && direct.IsValid)
|
|
return direct;
|
|
if (_stateCursors.TryGetValue("Normal", out var normal) && normal.IsValid)
|
|
return normal;
|
|
|
|
return default;
|
|
}
|
|
|
|
// ── Geometry ────────────────────────────────────────────────────────
|
|
/// <summary>X in the parent's local pixel space.</summary>
|
|
public float Left { get; set; }
|
|
public float Top { get; set; }
|
|
public float Width { get; set; }
|
|
public float Height { get; set; }
|
|
|
|
/// <summary>Absolute (screen-space) top-left, computed by walking Parent.</summary>
|
|
public Vector2 ScreenPosition
|
|
{
|
|
get
|
|
{
|
|
var p = new Vector2(Left, Top);
|
|
var parent = Parent;
|
|
while (parent is not null)
|
|
{
|
|
p += new Vector2(parent.Left, parent.Top);
|
|
parent = parent.Parent;
|
|
}
|
|
return p;
|
|
}
|
|
}
|
|
|
|
// ── State flags ─────────────────────────────────────────────────────
|
|
private bool _visible = true;
|
|
public bool Visible
|
|
{
|
|
get => _visible;
|
|
set
|
|
{
|
|
if (_visible == value) return;
|
|
|
|
// A top-level visibility transition is also an ownership boundary:
|
|
// focus/capture/modal state must be released before the subtree becomes
|
|
// unreachable to input. UiRoot filters these notifications through the
|
|
// registered-window manager, while ordinary child visibility changes
|
|
// remain cheap and behavior-neutral.
|
|
UiRoot? root = FindRoot();
|
|
root?.OnElementVisibilityChanging(this, value);
|
|
_visible = value;
|
|
root?.OnElementVisibilityChanged(this, value);
|
|
}
|
|
}
|
|
private bool _enabled = true;
|
|
public bool Enabled
|
|
{
|
|
get => _enabled;
|
|
set
|
|
{
|
|
if (_enabled == value) return;
|
|
_enabled = value;
|
|
OnEnabledChanged();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// If true, <see cref="HitTest"/> skips this element — the event
|
|
/// passes through to whatever is behind. Used by decoration widgets
|
|
/// (portrait frames, ornamental dividers).
|
|
/// </summary>
|
|
public bool ClickThrough { get; set; }
|
|
|
|
/// <summary>
|
|
/// Optional live visibility reader, evaluated once per tick. Markup
|
|
/// <c>visible="{Binding}"</c> uses this so a panel can show and hide itself
|
|
/// from its binding object's state without the owner touching UI objects.
|
|
/// </summary>
|
|
public Func<bool>? VisibleSource { get; set; }
|
|
|
|
/// <summary>
|
|
/// If true, <see cref="UiRoot"/> will set focus here on click,
|
|
/// routing WM_KEYDOWN / WM_CHAR to <see cref="OnEvent"/> as
|
|
/// <see cref="UiEventType.KeyDown"/> / <see cref="UiEventType.Char"/>.
|
|
/// </summary>
|
|
public bool AcceptsFocus { get; set; }
|
|
|
|
/// <summary>
|
|
/// True if this is a text-entry (edit box); used by focus routing
|
|
/// to suppress global hotkeys while typing.
|
|
/// </summary>
|
|
public bool IsEditControl { get; set; }
|
|
|
|
private int _zOrder;
|
|
|
|
/// <summary>Painter's-algorithm z-order within siblings. Higher = on top.</summary>
|
|
public int ZOrder
|
|
{
|
|
get => _zOrder;
|
|
set
|
|
{
|
|
if (_zOrder == value) return;
|
|
_zOrder = value;
|
|
Parent?.InvalidateChildOrder();
|
|
}
|
|
}
|
|
|
|
/// <summary>Window opacity (0..1) multiplied into this element's and its
|
|
/// descendants' background + sprite draws (text stays opaque). 1 = fully opaque.
|
|
/// Set on a top-level window (e.g. the chat frame) for retail's translucent chat.</summary>
|
|
public float Opacity { get; set; } = 1f;
|
|
|
|
/// <summary>If true, a left-drag on this element (or a non-draggable child of
|
|
/// it) repositions it as a movable window. Intended for top-level panels,
|
|
/// whose Left/Top are screen coordinates (Root sits at the origin).</summary>
|
|
public bool Draggable { get; set; }
|
|
|
|
/// <summary>Authored window-move handle (retail <c>UIElement_Dragbar</c>, element
|
|
/// class 2, <c>Register @ 0x0046C840</c>): a left-press inside this element's subtree
|
|
/// moves its top-level window even when that window is not whole-surface
|
|
/// <see cref="Draggable"/> (retail <c>StartMouseMoving @ 0x0046C760</c> calls
|
|
/// <c>UIElement::StartMovement</c> on the parent window). Set by the DAT widget
|
|
/// factory for Type-2 layout elements — e.g. the 600 x 5 strip along the top of the
|
|
/// combat/spell bar. Hovering it shows the window-move cursor.</summary>
|
|
public bool WindowMoveHandle { get; set; }
|
|
|
|
/// <summary>Clamp a dragged top-level window fully inside its parent. Retail
|
|
/// <c>gmRadarUI::MoveTo</c> enables this; most windows retain the toolkit default.</summary>
|
|
public bool ConstrainDragToParent { get; set; }
|
|
|
|
/// <summary>
|
|
/// Clamp resize growth to the current parent extent. This is distinct from
|
|
/// <see cref="ConstrainDragToParent"/>: retail's shared main-panel host may be
|
|
/// moved freely, but its bottom resizebar grows only to the desktop edge.
|
|
/// </summary>
|
|
public bool ConstrainResizeToParent { get; set; }
|
|
|
|
/// <summary>If true, a left-drag starting near this element's edge/corner
|
|
/// resizes it (window resize). Intended for top-level panels.</summary>
|
|
public bool Resizable { get; set; }
|
|
|
|
/// <summary>If true, a left-drag starting on this element is delivered to the
|
|
/// element (e.g. text selection) instead of moving/resizing an ancestor window.
|
|
/// Edge resize on a resizable ancestor still wins — only the interior move /
|
|
/// drag-drop candidacy is suppressed in favour of the element's own handling.</summary>
|
|
public bool CapturesPointerDrag { get; set; }
|
|
|
|
/// <summary>If true, a left-press-and-move on this element starts a DRAG-DROP
|
|
/// (<see cref="UiRoot"/> promotes to BeginDrag) rather than moving a Draggable
|
|
/// ancestor window — so an item cell inside the toolbar frame drags the item, not
|
|
/// the window. Distinct from <see cref="CapturesPointerDrag"/> (a self-driven
|
|
/// interior drag like text selection, which does NOT promote to BeginDrag). Default
|
|
/// false; overridden by drag sources (e.g. an occupied <see cref="UiItemSlot"/>).</summary>
|
|
public virtual bool IsDragSource => false;
|
|
|
|
/// <summary>If true, a left-press on this element is handled BY the element (it receives the Click
|
|
/// on release) instead of being captured as a whole-window move on a Draggable ancestor. Set by
|
|
/// interactive leaf widgets (e.g. <see cref="UiButton"/>) so they stay clickable inside a
|
|
/// whole-window-Draggable frame like the inventory window — where, without this, the IA-12
|
|
/// whole-window-drag swallows the press and the Click is never emitted. Distinct from
|
|
/// <see cref="IsDragSource"/> (starts a drag-drop) and <see cref="CapturesPointerDrag"/> (a
|
|
/// self-driven interior drag such as text selection). Default false.</summary>
|
|
public virtual bool HandlesClick => false;
|
|
|
|
/// <summary>
|
|
/// Whether pointer movement should continue reaching this element while it remains
|
|
/// hovered and no mouse capture is active. Composite controls use this to update
|
|
/// procedural sub-region hover state without broadening mouse-move delivery to every
|
|
/// retained widget.
|
|
/// </summary>
|
|
public virtual bool ReceivesHoverMouseMove => false;
|
|
|
|
/// <summary>Minimum size enforced while resizing.</summary>
|
|
public float MinWidth { get; set; } = 40f;
|
|
public float MinHeight { get; set; } = 40f;
|
|
|
|
/// <summary>Maximum size enforced while resizing (default unbounded).</summary>
|
|
public float MaxWidth { get; set; } = float.MaxValue;
|
|
public float MaxHeight { get; set; } = float.MaxValue;
|
|
|
|
/// <summary>Allow horizontal (width) resize. Ignored unless <see cref="Resizable"/>.</summary>
|
|
public bool ResizeX { get; set; } = true;
|
|
/// <summary>Allow vertical (height) resize. Ignored unless <see cref="Resizable"/>.</summary>
|
|
public bool ResizeY { get; set; } = true;
|
|
|
|
/// <summary>Which edges may start a resize, beyond the ResizeX/ResizeY axis gates. Default: all.
|
|
/// Set to e.g. <see cref="ResizeEdges.Bottom"/> to allow only a bottom-edge drag (the collapse toolbar).</summary>
|
|
public ResizeEdges ResizableEdges { get; set; } =
|
|
ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom;
|
|
|
|
/// <summary>Edges this element anchors to in its parent. Default Left|Top
|
|
/// (pinned top-left, fixed size — no reflow). Left|Right stretches width.</summary>
|
|
private AnchorEdges _anchors = AnchorEdges.Left | AnchorEdges.Top;
|
|
|
|
/// <summary>Edges this programmatic element anchors to in its parent. Assigning
|
|
/// this compatibility policy explicitly opts out of an imported DAT
|
|
/// <see cref="LayoutPolicy"/>.</summary>
|
|
public AnchorEdges Anchors
|
|
{
|
|
get => _anchors;
|
|
set
|
|
{
|
|
_anchors = value;
|
|
LayoutPolicy = null;
|
|
_anchorCaptured = false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Exact raw-edge policy for imported DAT elements. Null for roots and
|
|
/// programmatic widgets. Controllers may set null to opt out or call
|
|
/// <see cref="UiLayoutPolicy.Rebase"/> after intentional geometry changes.
|
|
/// </summary>
|
|
public UiLayoutPolicy? LayoutPolicy { get; set; }
|
|
|
|
// ── Tree structure ──────────────────────────────────────────────────
|
|
public UiElement? Parent { get; private set; }
|
|
|
|
private readonly List<UiElement> _children = new();
|
|
private UiElement[]? _childrenBackToFront;
|
|
private UiElement[]? _childrenFrontToBack;
|
|
public IReadOnlyList<UiElement> Children => _children;
|
|
|
|
public virtual void AddChild(UiElement child)
|
|
{
|
|
if (child.Parent is not null) child.Parent.RemoveChild(child);
|
|
child.Parent = this;
|
|
_children.Add(child);
|
|
InvalidateChildOrder();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail's <c>GetChildRecursive</c>: a depth-first search of
|
|
/// <paramref name="root"/> and every descendant (not just direct
|
|
/// children) for a widget carrying <paramref name="datElementId"/>.
|
|
/// Shared by <see cref="UiTabPanel"/>'s tab-table resolution and any
|
|
/// controller that needs to resolve an element that may be
|
|
/// DUPLICATED (same numeric id) across sibling subtrees — e.g. the
|
|
/// Options panel's Apply/Reset/Defaults buttons, which the
|
|
/// Character/Chat/Config pages each author under the SAME ids
|
|
/// (Campaign OP research doc §3.1), so a flat
|
|
/// <see cref="ImportedLayout.FindElement"/> id lookup over the whole
|
|
/// panel tree cannot reliably pick the right page's own instance —
|
|
/// scoping the search to one page's subtree root can.
|
|
/// </summary>
|
|
public static UiElement? FindDescendant(UiElement root, uint datElementId)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(root);
|
|
if (root.DatElementId == datElementId) return root;
|
|
foreach (UiElement child in root.Children)
|
|
{
|
|
UiElement? found = FindDescendant(child, datElementId);
|
|
if (found is not null) return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public virtual bool RemoveChild(UiElement child)
|
|
{
|
|
if (!_children.Contains(child)) return false;
|
|
FindRoot()?.OnSubtreeRemoving(child);
|
|
_children.Remove(child);
|
|
child.Parent = null;
|
|
InvalidateChildOrder();
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stable snapshots of the current sibling order. Retained UI mutation is
|
|
/// render-thread-owned, so the arrays can be reused until membership or a
|
|
/// child's Z-order changes. A traversal keeps its local array if a callback
|
|
/// mutates the tree, preserving the prior snapshot semantics without a
|
|
/// per-element allocation on every draw and overlay pass.
|
|
/// </summary>
|
|
internal UiElement[] ChildrenBackToFrontSnapshot()
|
|
{
|
|
if (_childrenBackToFront is not null)
|
|
return _childrenBackToFront;
|
|
|
|
_childrenBackToFront = _children.ToArray();
|
|
Array.Sort(
|
|
_childrenBackToFront,
|
|
static (a, b) => a.ZOrder.CompareTo(b.ZOrder));
|
|
return _childrenBackToFront;
|
|
}
|
|
|
|
internal UiElement[] ChildrenFrontToBackSnapshot()
|
|
{
|
|
if (_childrenFrontToBack is not null)
|
|
return _childrenFrontToBack;
|
|
|
|
UiElement[] backToFront = ChildrenBackToFrontSnapshot();
|
|
_childrenFrontToBack = new UiElement[backToFront.Length];
|
|
for (int source = backToFront.Length - 1, destination = 0;
|
|
source >= 0;
|
|
source--, destination++)
|
|
{
|
|
_childrenFrontToBack[destination] = backToFront[source];
|
|
}
|
|
|
|
return _childrenFrontToBack;
|
|
}
|
|
|
|
private void InvalidateChildOrder()
|
|
{
|
|
_childrenBackToFront = null;
|
|
_childrenFrontToBack = null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// True if this widget draws its full appearance itself and REPRODUCES its dat
|
|
/// sub-elements procedurally (3-slice caps, button labels, scroll arrows, popup
|
|
/// rows…) — so the <see cref="AcDream.App.UI.Layout.LayoutImporter"/> must NOT build
|
|
/// those dat child elements as separate widgets (they would double-draw and, worse,
|
|
/// steal pointer/focus from the behavioral widget). All registered behavioral widgets
|
|
/// (Meter/Menu/Button/Scrollbar/Text/Field) return <c>true</c>; the generic container
|
|
/// (<see cref="AcDream.App.UI.Layout.UiDatElement"/>) and panels return <c>false</c>
|
|
/// and recurse their children normally. Mirrors retail, where each
|
|
/// <c>UIElement_X::DrawSelf</c> owns its internal structure.
|
|
/// </summary>
|
|
public virtual bool ConsumesDatChildren => false;
|
|
|
|
// ── Virtual overrides ───────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Draw THIS element (not its children). Children are composited by
|
|
/// <see cref="UiRoot"/> after this returns.
|
|
/// </summary>
|
|
protected virtual void OnDraw(UiRenderContext ctx) { }
|
|
|
|
/// <summary>
|
|
/// Draw AFTER this element's own children, but still within this element's
|
|
/// transform/alpha (NOT a global pass like <see cref="OnDrawOverlay"/>). Use for a
|
|
/// window FRAME border, which must be the outermost layer drawn OVER its content's
|
|
/// edges (so content can't poke through the frame), while the frame's center fill
|
|
/// stays a background in <see cref="OnDraw"/>. Default: nothing.
|
|
/// </summary>
|
|
protected virtual void OnDrawAfterChildren(UiRenderContext ctx) { }
|
|
|
|
/// <summary>
|
|
/// Draw content that must sit ON TOP of the ENTIRE UI, regardless of this
|
|
/// element's position in the tree — open menus, dropdowns, tooltips. Called in
|
|
/// a SECOND traversal after the whole tree's <see cref="OnDraw"/> pass, with the
|
|
/// same accumulated transform/alpha this element had during its normal draw.
|
|
/// Retail spawns popups as ROOT elements (UIElement_Menu::MakePopup) for exactly
|
|
/// this reason; this is the equivalent without reparenting. Default: nothing.
|
|
/// </summary>
|
|
protected virtual void OnDrawOverlay(UiRenderContext ctx) { }
|
|
|
|
/// <summary>
|
|
/// Whether THIS element's own draw AND its descendants' drawing/hit-testing are
|
|
/// clipped to this element's local bounds. THIS IS THE DEFAULT (true) FOR EVERY
|
|
/// ELEMENT — CT-GF1 port of retail's ancestor-clip chain: <c>UIRegion::DrawHere
|
|
/// @0x0069FA30</c> takes the element's screen <c>Box2D</c> plus a
|
|
/// <c>SmartArray<Box2D></c> of inherited clip rects, intersects them (the
|
|
/// min/max clamp loop @0x0069FAA7..0x0069FB82), and draws — <c>EraseSelf</c>/
|
|
/// <c>DrawChildren</c>/<c>DrawSelf</c> ALL receive the intersected rect — ONLY
|
|
/// when the intersection is non-empty (the <c>var_24</c> gate @0x0069FB8E). An
|
|
/// element positioned outside its parent's box therefore silently disappears in
|
|
/// retail, exactly like <see cref="UiRenderContext.PushClip"/>/
|
|
/// <see cref="UiRenderContext.PopClip"/> now does for every element by default —
|
|
/// <see cref="DrawSelfAndChildren"/>'s fix-round shape (S2) pushes right after
|
|
/// <c>PushAlpha</c> and wraps <c>OnDraw</c> + the children walk +
|
|
/// <c>OnDrawAfterChildren</c> in ONE block, the literal <c>DrawHere</c> shape
|
|
/// (retail clips the element's OWN <c>DrawSelf</c> too, not just its children —
|
|
/// <c>UIElement_Text::DrawSelf @0x00467AA0</c> locks glyph blits to its own
|
|
/// clipped surface rect; <c>UIRegion::DrawSelf @0x0069F1A0</c> blits per clip
|
|
/// rect). Not just the scrollable listboxes that opted in before this default
|
|
/// flipped (owner gate finding: the Titles page's authored divider 0x10000530
|
|
/// escaped the Character window at the CT6-correct 372px mounted default —
|
|
/// retail clips it away; acdream drew it floating above the window).
|
|
///
|
|
/// <para>
|
|
/// Override to <see langword="false"/> ONLY for a widget that must draw or accept
|
|
/// input beyond its own bounds by deliberate design — today just
|
|
/// <see cref="UiMenu"/> (whose popup, and its own out-of-bounds <c>OnHitTest</c>
|
|
/// override, stands in for retail's separate top-level popup region — see
|
|
/// <see cref="ExpandsClipForPopup"/> for the drawing half of that opt-out and the
|
|
/// divergence register row it cites) and <see cref="UiRoot"/> (whose own region
|
|
/// IS the screen — the viewport itself already scissors it, so narrowing to
|
|
/// <c>(0,0,Width,Height)</c> here would blank the whole UI the moment the root's
|
|
/// own tracked size is ever momentarily zero, e.g. before the first resize event
|
|
/// lands).
|
|
/// </para>
|
|
/// </summary>
|
|
protected virtual bool ClipsChildren => true;
|
|
|
|
/// <summary>
|
|
/// True when this element's <see cref="OnDrawOverlay"/> content must ignore the
|
|
/// standard ancestor clip chain that <see cref="ClipsChildren"/> now threads through
|
|
/// every element by default (CT-GF1). Retail spawns popups/dropdowns as SEPARATE
|
|
/// top-level regions (<c>UIElement_Menu::MakePopup</c>), so only the SCREEN clips
|
|
/// them — never an intervening window or panel's own client rect. Ours draws a
|
|
/// popup INLINE from its owning widget instead of reparenting to a new root (see
|
|
/// <see cref="OnDrawOverlay"/>'s own doc comment — that second traversal already
|
|
/// exists so popups composite "regardless of this element's position in the
|
|
/// tree"), so without this escape hatch the new default clip would wrongly cut off
|
|
/// a popup that legitimately extends outside its owning window — e.g. a dropdown
|
|
/// opened near the bottom of a short window. Default false (ordinary overlay
|
|
/// content, if any is ever added beyond <see cref="UiMenu"/>, stays clipped like
|
|
/// everything else). See the divergence register row this property's introducing
|
|
/// commit adds for the seam it stands in for.
|
|
/// </summary>
|
|
protected virtual bool ExpandsClipForPopup => false;
|
|
|
|
/// <summary>Per-frame tick (animations, timers, caret blink).</summary>
|
|
protected virtual void OnTick(double deltaSeconds) { }
|
|
|
|
/// <summary>Called synchronously after <see cref="Enabled"/> changes.</summary>
|
|
protected virtual void OnEnabledChanged() { }
|
|
|
|
/// <summary>
|
|
/// Custom hit-test override. Default is a rectangle containment
|
|
/// check on (<see cref="Width"/>, <see cref="Height"/>).
|
|
/// </summary>
|
|
protected virtual bool OnHitTest(float localX, float localY)
|
|
=> localX >= 0f && localX < Width && localY >= 0f && localY < Height;
|
|
|
|
/// <summary>
|
|
/// Event handler. Return <c>true</c> to consume the event (the
|
|
/// <see cref="UiRoot"/> will stop propagation). Return <c>false</c>
|
|
/// to let ancestors / fall-through handle it.
|
|
/// </summary>
|
|
public virtual bool OnEvent(in UiEvent e) => false;
|
|
|
|
/// <summary>The data this element carries when a drag begins. <see cref="UiRoot"/>
|
|
/// pulls this on drag-promote; a NULL return CANCELS the drag (retail:
|
|
/// ItemList_BeginDrag only arms an occupied cell). Default null = not draggable.</summary>
|
|
public virtual object? GetDragPayload() => null;
|
|
|
|
/// <summary>The texture <see cref="UiRoot"/> paints at the cursor while this element
|
|
/// is the drag source: (GL handle, width, height). Null = no ghost. Keeps
|
|
/// <see cref="UiRoot"/> item-agnostic. Retail analog: m_dragIcon (decomp 229738).</summary>
|
|
public virtual (uint tex, int w, int h)? GetDragGhost() => null;
|
|
|
|
/// <summary>
|
|
/// Notifies the source widget when the root starts or finishes carrying its drag payload.
|
|
/// This is a retained-widget lifecycle hook rather than item-specific root logic. Retail's
|
|
/// item implementation uses it to show/hide <c>m_elem_Icon_Ghosted</c> around a physical
|
|
/// item drag (<c>ItemList_BeginDrag</c> 0x004e32d0; <c>SetWaitingState</c> 0x004e11b0).
|
|
/// </summary>
|
|
internal virtual void SetDragSourceActive(bool active, object? payload) { }
|
|
|
|
/// <summary>
|
|
/// Tooltip text for this widget. Retail fires event 0x07 after the configured
|
|
/// hover delay (0.25 seconds by default), then queries the widget's virtual "GetString"
|
|
/// (vtable +0x88) to render the tooltip body.
|
|
/// </summary>
|
|
public virtual string? GetTooltipText() => null;
|
|
|
|
// ── Framework entry points (internal, called by UiRoot) ─────────────
|
|
|
|
internal void DrawSelfAndChildren(UiRenderContext ctx)
|
|
{
|
|
if (!Visible) return;
|
|
|
|
// Translate into our local space + push this window's opacity (multiplies into
|
|
// descendants' sprite, rect, AND text draws — CH6c ported DrawStringDat/DrawString
|
|
// through the same ApplyAlpha chokepoint as sprites/rects, matching retail's
|
|
// ChatInterface::SetOpacity (0x004F3120), which fades the whole composited window
|
|
// surface, chrome and glyphs together, not text-stays-sharp over a translucent panel).
|
|
ctx.PushTransform(Left, Top);
|
|
ctx.PushAlpha(Opacity);
|
|
// CT-GF1 fix round (S2): the clip now wraps OnDraw + children +
|
|
// OnDrawAfterChildren — the LITERAL UIRegion::DrawHere @0x0069FA30 shape,
|
|
// which clips the element's OWN DrawSelf to the intersected rect, not just its
|
|
// children (UIElement_Text::DrawSelf @0x00467AA0 locks glyph blits to arg3;
|
|
// UIRegion::DrawSelf @0x0069F1A0 blits per clip rect). Pushed right after
|
|
// PushAlpha, popped in the one finally below, so it is balanced regardless of
|
|
// which branch below runs.
|
|
bool clipsChildren = ClipsChildren;
|
|
if (clipsChildren)
|
|
ctx.PushClip(0f, 0f, Width, Height);
|
|
try
|
|
{
|
|
// N2 fix round: retail's var_24 gate @0x0069FB8E — an EMPTY intersected
|
|
// clip skips EraseSelf/DrawChildren/DrawSelf outright for the whole
|
|
// subtree. DrawOverlays (the popup's SEPARATE second traversal) does not
|
|
// share this walk or its clip-stack state, so an open UiMenu popup nested
|
|
// here keeps drawing there regardless of this cull — see
|
|
// UiAncestorClipTests' menu-inside-a-fully-clipped-window coverage.
|
|
if (!ctx.CurrentClipIsEmpty)
|
|
{
|
|
OnDraw(ctx);
|
|
|
|
// Anchor layout: reflow children to this element's current size.
|
|
for (int i = 0; i < _children.Count; i++)
|
|
_children[i].ApplyAnchor(Width, Height);
|
|
|
|
// Children painted back-to-front (lowest ZOrder first).
|
|
if (_children.Count > 0)
|
|
{
|
|
UiElement[] ordered = ChildrenBackToFrontSnapshot();
|
|
for (int i = 0; i < ordered.Length; i++)
|
|
ordered[i].DrawSelfAndChildren(ctx);
|
|
}
|
|
|
|
// Foreground pass for this element (e.g. a window frame's border drawn
|
|
// OVER its content's edges). Default no-op for ordinary elements.
|
|
OnDrawAfterChildren(ctx);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (clipsChildren)
|
|
ctx.PopClip();
|
|
ctx.PopAlpha();
|
|
ctx.PopTransform();
|
|
}
|
|
}
|
|
|
|
/// <summary>Second draw traversal: re-walks the tree applying the same
|
|
/// transform/alpha as <see cref="DrawSelfAndChildren"/> and calls
|
|
/// <see cref="OnDrawOverlay"/> on each element, so popups composite on top of
|
|
/// everything drawn in the main pass (dat-font glyphs and sprites share one
|
|
/// submission-ordered bucket, so later submissions win).</summary>
|
|
internal void DrawOverlays(UiRenderContext ctx)
|
|
{
|
|
if (!Visible) return;
|
|
ctx.PushTransform(Left, Top);
|
|
ctx.PushAlpha(Opacity);
|
|
try
|
|
{
|
|
// ExpandsClipForPopup (CT-GF1): a popup drawn here must ignore whatever
|
|
// ancestor clip the walk down to this element accumulated — see the
|
|
// property's own doc comment for the retail-parity rationale.
|
|
if (ExpandsClipForPopup)
|
|
{
|
|
ctx.PushClipUnbounded();
|
|
try { OnDrawOverlay(ctx); }
|
|
finally { ctx.PopClip(); }
|
|
}
|
|
else
|
|
{
|
|
OnDrawOverlay(ctx);
|
|
}
|
|
if (_children.Count > 0)
|
|
{
|
|
bool clipsChildren = ClipsChildren;
|
|
if (clipsChildren)
|
|
ctx.PushClip(0f, 0f, Width, Height);
|
|
try
|
|
{
|
|
UiElement[] ordered = ChildrenBackToFrontSnapshot();
|
|
for (int i = 0; i < ordered.Length; i++)
|
|
ordered[i].DrawOverlays(ctx);
|
|
}
|
|
finally
|
|
{
|
|
if (clipsChildren)
|
|
ctx.PopClip();
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
ctx.PopAlpha();
|
|
ctx.PopTransform();
|
|
}
|
|
}
|
|
|
|
internal void TickSelfAndChildren(double dt)
|
|
{
|
|
// Evaluated before the Visible gate on purpose: the gate returns early
|
|
// for a hidden element, so a source read after it could turn an element
|
|
// off but never back on.
|
|
if (VisibleSource is { } visibility)
|
|
Visible = visibility();
|
|
if (!Visible) return;
|
|
OnTick(dt);
|
|
for (int i = 0; i < _children.Count; i++)
|
|
_children[i].TickSelfAndChildren(dt);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Top-down, children-first hit-test. <paramref name="localX"/> /
|
|
/// <paramref name="localY"/> are in THIS element's local space.
|
|
/// Returns the topmost descendant (or this) at the point, or null.
|
|
/// </summary>
|
|
internal UiElement? HitTest(float localX, float localY)
|
|
{
|
|
if (!Visible || !Enabled) return null;
|
|
if (ClipsChildren
|
|
&& (localX < 0f || localX >= Width || localY < 0f || localY >= Height))
|
|
return null;
|
|
|
|
// Children first, in reverse Z-order (topmost first). ClickThrough means
|
|
// THIS element is transparent to the pointer — but its children are NOT.
|
|
// A ClickThrough container (e.g. a UiDatElement panel that hosts the chat
|
|
// input / transcript) must still let the pointer reach its behavioral
|
|
// children, so the ClickThrough check happens AFTER the child walk, gating
|
|
// only whether THIS element claims the hit.
|
|
if (_children.Count > 0)
|
|
{
|
|
UiElement[] ordered = ChildrenFrontToBackSnapshot();
|
|
for (int i = 0; i < ordered.Length; i++)
|
|
{
|
|
var c = ordered[i];
|
|
var childHit = c.HitTest(localX - c.Left, localY - c.Top);
|
|
if (childHit is not null) return childHit;
|
|
}
|
|
}
|
|
|
|
if (ClickThrough) return null;
|
|
return OnHitTest(localX, localY) ? this : null;
|
|
}
|
|
|
|
// ── Anchor layout ────────────────────────────────────────────────────
|
|
|
|
private bool _anchorCaptured;
|
|
private float _amL, _amT, _amR, _amB, _aw0, _ah0;
|
|
|
|
/// <summary>Reposition/resize this element per <see cref="Anchors"/>, keeping
|
|
/// the margins captured (at first layout / design size) to each anchored edge.
|
|
/// Called by the parent each frame before drawing children.</summary>
|
|
internal void ApplyAnchor(float parentW, float parentH)
|
|
{
|
|
if (LayoutPolicy is not null)
|
|
{
|
|
var current = UiPixelRect.FromPositionAndSize(
|
|
(int)Left,
|
|
(int)Top,
|
|
(int)Width,
|
|
(int)Height);
|
|
var parent = UiPixelRect.FromPositionAndSize(0, 0, (int)parentW, (int)parentH);
|
|
var next = LayoutPolicy.Apply(current, parent);
|
|
Left = next.X0;
|
|
Top = next.Y0;
|
|
Width = next.Width;
|
|
Height = next.Height;
|
|
return;
|
|
}
|
|
|
|
if (Anchors == AnchorEdges.None) return;
|
|
if (!_anchorCaptured)
|
|
{
|
|
_amL = Left; _amT = Top;
|
|
_amR = parentW - (Left + Width);
|
|
_amB = parentH - (Top + Height);
|
|
_aw0 = Width; _ah0 = Height;
|
|
_anchorCaptured = true;
|
|
}
|
|
var (x, y, w, h) = ComputeAnchoredRect(Anchors, _amL, _amT, _amR, _amB, _aw0, _ah0, parentW, parentH);
|
|
Left = x; Top = y; Width = w; Height = h;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Make the current geometry the new layout baseline after an intentional
|
|
/// controller/runtime change. Compatibility anchors recapture their margins on
|
|
/// the next layout pass; imported raw-edge policies rebase immediately against
|
|
/// the current parent rect.
|
|
/// </summary>
|
|
internal void ResetAnchorCapture()
|
|
{
|
|
_anchorCaptured = false;
|
|
if (LayoutPolicy is null || Parent is null) return;
|
|
|
|
LayoutPolicy.Rebase(
|
|
UiPixelRect.FromPositionAndSize(
|
|
(int)Left,
|
|
(int)Top,
|
|
(int)Width,
|
|
(int)Height),
|
|
UiPixelRect.FromPositionAndSize(
|
|
0,
|
|
0,
|
|
(int)Parent.Width,
|
|
(int)Parent.Height));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Capture the current mounted rect as the baseline for a compatibility
|
|
/// anchor policy immediately. Hidden trees do not receive a layout traversal,
|
|
/// so lazy capture after a persistence resize would use the wrong parent size.
|
|
/// </summary>
|
|
internal void CaptureCurrentAnchorBaseline()
|
|
{
|
|
if (Parent is null || Anchors == AnchorEdges.None) return;
|
|
_anchorCaptured = false;
|
|
ApplyAnchor(Parent.Width, Parent.Height);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rebase each immediate child after a host deliberately changes this element's
|
|
/// design extent. This is used when a production DAT root contains auxiliary
|
|
/// space that is cropped by its retail window mount; descendants must reflow
|
|
/// from the mounted content rect, not from the uncropped LayoutDesc display box.
|
|
/// </summary>
|
|
internal void RebaseChildLayoutBaselines()
|
|
{
|
|
foreach (var child in _children)
|
|
child.ResetAnchorCapture();
|
|
}
|
|
|
|
/// <summary>Walk up to the owning <see cref="UiRoot"/> (the top of the tree), or null
|
|
/// if this element is not attached. Lets a widget reach focus/capture services — e.g.
|
|
/// a chat input blurring itself (exiting write mode) after submit.</summary>
|
|
internal UiRoot? FindRoot()
|
|
{
|
|
UiElement e = this;
|
|
while (e.Parent is not null) e = e.Parent;
|
|
return e as UiRoot;
|
|
}
|
|
|
|
/// <summary>Compute an anchored child rect. Left&Right ⇒ stretch width
|
|
/// (keep both margins); Right only ⇒ pin to right at fixed width; otherwise
|
|
/// pin left at fixed width. Same logic vertically.</summary>
|
|
public static (float x, float y, float w, float h) ComputeAnchoredRect(
|
|
AnchorEdges a, float mL, float mT, float mR, float mB,
|
|
float w0, float h0, float parentW, float parentH)
|
|
{
|
|
bool l = (a & AnchorEdges.Left) != 0, r = (a & AnchorEdges.Right) != 0;
|
|
float x, w;
|
|
if (l && r) { x = mL; w = parentW - mR - mL; }
|
|
else if (r) { w = w0; x = parentW - mR - w0; }
|
|
else { x = mL; w = w0; }
|
|
|
|
bool t = (a & AnchorEdges.Top) != 0, b = (a & AnchorEdges.Bottom) != 0;
|
|
float y, h;
|
|
if (t && b) { y = mT; h = parentH - mB - mT; }
|
|
else if (b) { h = h0; y = parentH - mB - h0; }
|
|
else { y = mT; h = h0; }
|
|
|
|
if (w < 0) w = 0;
|
|
if (h < 0) h = 0;
|
|
return (x, y, w, h);
|
|
}
|
|
}
|