fix(D.2b): behavioral widgets are leaf — ConsumesDatChildren (chat menu open)

The generalized channel menu wouldn't open: the factory recursed the Type-6
menu element's dat children, building its invisible Type-12 label child as a
UiText. Hit-testing is children-first and UiText consumes MouseDown (selection),
so the label child swallowed the menu button click and the dropdown never opened.
The transcript similarly gained an invisible Ghosted-button child (a 16x16
selection dead-zone). The old hand-made build never had these — it skipped Type 12
and hand-placed the widgets with no children.

Fix: behavioral widgets (Meter/Menu/Button/Scrollbar/Text/Field) draw their full
appearance and reproduce their dat sub-elements procedurally, so they are LEAF —
the importer must not build their dat children as separate (click-stealing)
widgets. Add UiElement.ConsumesDatChildren (default false; the 6 behavioral
widgets override true) and gate LayoutImporter recursion on it (replacing the
UiMeter-only special case). Only generic containers (UiDatElement, panels) recurse.

Visually confirmed in the live client (channel menu opens; General/Trade selected
and sent). Vitals unchanged (UiMeter was already leaf). Full suite: 404 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-16 18:36:40 +02:00
parent 83076cdbb6
commit d7002552bc
8 changed files with 45 additions and 5 deletions

View file

@ -106,11 +106,14 @@ public static class LayoutImporter
if (info.Id != 0) byId[info.Id] = w; if (info.Id != 0) byId[info.Id] = w;
// Meters consume their own children: DatWidgetFactory already extracted the // Behavioral widgets that draw their full appearance + reproduce their dat
// slice-sprite ids from the grandchild image elements during UiMeter construction. // sub-elements procedurally (Meter's 3-slice, Menu's label/rows, Field/Text caps,
// Adding those children as separate UiElement nodes would produce duplicate // Button labels, Scrollbar arrows) CONSUME their dat children — building those as
// geometry and wrong widget semantics. Every other element type recurses normally. // separate widgets double-draws and lets an invisible child steal pointer/focus
if (w is not UiMeter) // from the behavioral widget (e.g. the channel Menu's label child intercepting the
// button click). Only generic containers (UiDatElement, panels) recurse. See
// UiElement.ConsumesDatChildren.
if (!w.ConsumesDatChildren)
{ {
foreach (var child in info.Children) foreach (var child in info.Children)
{ {

View file

@ -71,6 +71,10 @@ public sealed class UiButton : UiElement
// else ActiveState stays "" (DirectState) // else ActiveState stays "" (DirectState)
} }
/// <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> /// <summary>
/// Returns the File id for the current <see cref="ActiveState"/>, falling back to /// Returns the File id for the current <see cref="ActiveState"/>, falling back to
/// the DirectState ("" key) if the named state is absent. /// the DirectState ("" key) if the named state is absent.

View file

@ -146,6 +146,19 @@ public abstract class UiElement
return true; return true;
} }
/// <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 ─────────────────────────────────────────────── // ── Virtual overrides ───────────────────────────────────────────────
/// <summary> /// <summary>

View file

@ -71,6 +71,10 @@ public sealed class UiField : UiElement
CapturesPointerDrag = true; // interior drag selects, doesn't move the window CapturesPointerDrag = true; // interior drag selects, doesn't move the window
} }
/// <summary>The field draws its own background + caret + caps; its dat cap sub-elements
/// are reproduced procedurally, so the importer must not build them as widgets.</summary>
public override bool ConsumesDatChildren => true;
// ── Editing primitives ────────────────────────────────────────────── // ── Editing primitives ──────────────────────────────────────────────
public void InsertChar(char c) public void InsertChar(char c)

View file

@ -80,6 +80,10 @@ public sealed class UiMenu : UiElement
public UiMenu() { CapturesPointerDrag = true; } public UiMenu() { CapturesPointerDrag = true; }
/// <summary>The menu draws its own button face + popup; its dat label/row children
/// must NOT be built (an invisible label child would intercept the button click).</summary>
public override bool ConsumesDatChildren => true;
protected override void OnDraw(UiRenderContext ctx) protected override void OnDraw(UiRenderContext ctx)
{ {
var resolve = SpriteResolve; var resolve = SpriteResolve;

View file

@ -57,6 +57,10 @@ public sealed class UiMeter : UiElement
public UiMeter() { ClickThrough = true; } public UiMeter() { ClickThrough = true; }
/// <summary>The meter draws its own 3-slice bars; the importer must not build its
/// grandchild slice/text elements as separate widgets.</summary>
public override bool ConsumesDatChildren => true;
/// <summary>Clamp <paramref name="pct"/> to [0,1] and return the fill rect /// <summary>Clamp <paramref name="pct"/> to [0,1] and return the fill rect
/// (local px) for a bar of <paramref name="w"/> x <paramref name="h"/>.</summary> /// (local px) for a bar of <paramref name="w"/> x <paramref name="h"/>.</summary>
public static (float x, float y, float w, float h) ComputeFillRect( public static (float x, float y, float w, float h) ComputeFillRect(

View file

@ -61,6 +61,10 @@ public sealed class UiScrollbar : UiElement
public UiScrollbar() { CapturesPointerDrag = true; } public UiScrollbar() { CapturesPointerDrag = true; }
/// <summary>The scrollbar draws its own track/thumb/arrows; its dat up/down button
/// children are reproduced procedurally, so the importer must not build them.</summary>
public override bool ConsumesDatChildren => true;
/// <summary> /// <summary>
/// Computes the thumb rectangle (local y origin and height) within the track area /// Computes the thumb rectangle (local y origin and height) within the track area
/// between the two end buttons. Ports retail <c>UIElement_Scrollbar::UpdateLayout /// between the two end buttons. Ports retail <c>UIElement_Scrollbar::UpdateLayout

View file

@ -91,6 +91,10 @@ public sealed class UiText : UiElement
CapturesPointerDrag = true; // interior drag selects, doesn't move the window CapturesPointerDrag = true; // interior drag selects, doesn't move the window
} }
/// <summary>The text view draws its own lines + background; any dat sub-elements
/// (scroll indicators, caps) are not built as separate widgets by the importer.</summary>
public override bool ConsumesDatChildren => true;
/// <summary> /// <summary>
/// Clamp a scroll offset to [0, max] where max = content-height - view-height /// Clamp a scroll offset to [0, max] where max = content-height - view-height
/// (never negative — when everything fits, scroll is pinned to 0). Exposed for tests. /// (never negative — when everything fits, scroll is pinned to 0). Exposed for tests.