From d7002552bc95d6bf8df1a57092ff2e2cec5fa917 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 18:36:40 +0200 Subject: [PATCH] =?UTF-8?q?fix(D.2b):=20behavioral=20widgets=20are=20leaf?= =?UTF-8?q?=20=E2=80=94=20ConsumesDatChildren=20(chat=20menu=20open)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.App/UI/Layout/LayoutImporter.cs | 13 ++++++++----- src/AcDream.App/UI/UiButton.cs | 4 ++++ src/AcDream.App/UI/UiElement.cs | 13 +++++++++++++ src/AcDream.App/UI/UiField.cs | 4 ++++ src/AcDream.App/UI/UiMenu.cs | 4 ++++ src/AcDream.App/UI/UiMeter.cs | 4 ++++ src/AcDream.App/UI/UiScrollbar.cs | 4 ++++ src/AcDream.App/UI/UiText.cs | 4 ++++ 8 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index 018cbb07..0db0f61d 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -106,11 +106,14 @@ public static class LayoutImporter if (info.Id != 0) byId[info.Id] = w; - // Meters consume their own children: DatWidgetFactory already extracted the - // slice-sprite ids from the grandchild image elements during UiMeter construction. - // Adding those children as separate UiElement nodes would produce duplicate - // geometry and wrong widget semantics. Every other element type recurses normally. - if (w is not UiMeter) + // Behavioral widgets that draw their full appearance + reproduce their dat + // sub-elements procedurally (Meter's 3-slice, Menu's label/rows, Field/Text caps, + // Button labels, Scrollbar arrows) CONSUME their dat children — building those as + // separate widgets double-draws and lets an invisible child steal pointer/focus + // 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) { diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index c6c5be26..6c31797d 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -71,6 +71,10 @@ public sealed class UiButton : UiElement // else ActiveState stays "" (DirectState) } + /// 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. + public override bool ConsumesDatChildren => true; + /// /// Returns the File id for the current , falling back to /// the DirectState ("" key) if the named state is absent. diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index a65a573b..7e1df4ad 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -146,6 +146,19 @@ public abstract class UiElement return true; } + /// + /// 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 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 true; the generic container + /// () and panels return false + /// and recurse their children normally. Mirrors retail, where each + /// UIElement_X::DrawSelf owns its internal structure. + /// + public virtual bool ConsumesDatChildren => false; + // ── Virtual overrides ─────────────────────────────────────────────── /// diff --git a/src/AcDream.App/UI/UiField.cs b/src/AcDream.App/UI/UiField.cs index ab9b8750..9bc7ef32 100644 --- a/src/AcDream.App/UI/UiField.cs +++ b/src/AcDream.App/UI/UiField.cs @@ -71,6 +71,10 @@ public sealed class UiField : UiElement CapturesPointerDrag = true; // interior drag selects, doesn't move the window } + /// 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. + public override bool ConsumesDatChildren => true; + // ── Editing primitives ────────────────────────────────────────────── public void InsertChar(char c) diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs index 85241a68..c10bd419 100644 --- a/src/AcDream.App/UI/UiMenu.cs +++ b/src/AcDream.App/UI/UiMenu.cs @@ -80,6 +80,10 @@ public sealed class UiMenu : UiElement public UiMenu() { CapturesPointerDrag = true; } + /// 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). + public override bool ConsumesDatChildren => true; + protected override void OnDraw(UiRenderContext ctx) { var resolve = SpriteResolve; diff --git a/src/AcDream.App/UI/UiMeter.cs b/src/AcDream.App/UI/UiMeter.cs index f93737a3..b5ee4a40 100644 --- a/src/AcDream.App/UI/UiMeter.cs +++ b/src/AcDream.App/UI/UiMeter.cs @@ -57,6 +57,10 @@ public sealed class UiMeter : UiElement public UiMeter() { ClickThrough = true; } + /// The meter draws its own 3-slice bars; the importer must not build its + /// grandchild slice/text elements as separate widgets. + public override bool ConsumesDatChildren => true; + /// Clamp to [0,1] and return the fill rect /// (local px) for a bar of x . public static (float x, float y, float w, float h) ComputeFillRect( diff --git a/src/AcDream.App/UI/UiScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs index 99e4dcdc..d574b597 100644 --- a/src/AcDream.App/UI/UiScrollbar.cs +++ b/src/AcDream.App/UI/UiScrollbar.cs @@ -61,6 +61,10 @@ public sealed class UiScrollbar : UiElement public UiScrollbar() { CapturesPointerDrag = true; } + /// The scrollbar draws its own track/thumb/arrows; its dat up/down button + /// children are reproduced procedurally, so the importer must not build them. + public override bool ConsumesDatChildren => true; + /// /// Computes the thumb rectangle (local y origin and height) within the track area /// between the two end buttons. Ports retail UIElement_Scrollbar::UpdateLayout diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index 439350db..b5aa838a 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -91,6 +91,10 @@ public sealed class UiText : UiElement CapturesPointerDrag = true; // interior drag selects, doesn't move the window } + /// The text view draws its own lines + background; any dat sub-elements + /// (scroll indicators, caps) are not built as separate widgets by the importer. + public override bool ConsumesDatChildren => true; + /// /// 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.