From 8ee3d89feb65157505e8f2e2d245883805d56b49 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 23 Jun 2026 10:08:59 +0200 Subject: [PATCH] =?UTF-8?q?fix(D.2b):=20Slice=202=20=E2=80=94=20buttons=20?= =?UTF-8?q?inside=20a=20whole-window-Draggable=20frame=20get=20their=20Cli?= =?UTF-8?q?ck?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visual gate 2 (user): the "Slots" toggle caption was visible but unclickable. Root cause (UiRoot.OnMouseDown/OnMouseUp): a left-press on a non-drag-source widget inside a whole-window-Draggable frame (the inventory window's IA-12 drag) set _windowDragTarget; OnMouseUp then early-returned before emitting the Click. So the paperdoll Slots button (the first plain button inside the draggable inventory frame) never received its click. Chat/toolbar buttons escape this — their frames aren't whole-window-draggable. Fix (toolkit, root cause not band-aid): add UiElement.HandlesClick (a virtual opt-out parallel to IsDragSource); UiButton overrides it true; OnMouseDown routes a HandlesClick press to the widget (like CapturesPointerDrag) instead of the window-drag, so OnMouseUp emits the Click. 2 regression tests lock it (HandlesClick widget in a Draggable frame emits Click; a plain one doesn't). Build + full App suite green (596, +2). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiButton.cs | 5 ++ src/AcDream.App/UI/UiElement.cs | 9 ++++ src/AcDream.App/UI/UiRoot.cs | 11 +++-- .../AcDream.App.Tests/UI/UiRootInputTests.cs | 47 +++++++++++++++++++ 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index 6c31797d..4936ada4 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -75,6 +75,11 @@ public sealed class UiButton : UiElement /// procedurally, so the importer must not build the button's children as widgets. public override bool ConsumesDatChildren => true; + /// A button is interactive — it must receive its Click even inside a whole-window-Draggable + /// frame (e.g. the paperdoll "Slots" toggle in the inventory window), so it opts out of the + /// IA-12 whole-window-drag that would otherwise swallow the press. + public override bool HandlesClick => 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 72a49d63..20c328e4 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -121,6 +121,15 @@ public abstract class UiElement /// false; overridden by drag sources (e.g. an occupied ). public virtual bool IsDragSource => false; + /// 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. ) 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 + /// (starts a drag-drop) and (a + /// self-driven interior drag such as text selection). Default false. + public virtual bool HandlesClick => false; + /// Minimum size enforced while resizing. public float MinWidth { get; set; } = 40f; public float MinHeight { get; set; } = 40f; diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index 11d35b55..7740fddc 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -275,12 +275,13 @@ public sealed class UiRoot : UiElement // the bar movable by its empty cells / chrome. _dragCandidate = true; } - else if (target.CapturesPointerDrag) + else if (target.CapturesPointerDrag || target.HandlesClick) { - // The pressed widget owns interior drags (e.g. text selection): - // do NOT move the ancestor window. The already-dispatched MouseDown - // event + SetCapture(target) let the target drive its own drag via - // the MouseMove events it receives while captured. + // The pressed widget owns its pointer interaction — either an interior drag (e.g. text + // selection, CapturesPointerDrag) or a click it must receive (e.g. a UiButton, + // HandlesClick). Either way do NOT move the ancestor window. The already-dispatched + // MouseDown + SetCapture(target) let the target handle it; on release OnMouseUp emits + // the Click over the same element. (A HandlesClick widget is not a drag candidate.) _dragCandidate = false; } else if (window.Draggable) diff --git a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs index e31da6c7..49799ae1 100644 --- a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs +++ b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs @@ -14,6 +14,53 @@ public class UiRootInputTests Assert.Equal(AnchorEdges.None, panel.Anchors); } + private sealed class ClickRecorder : UiElement + { + private readonly bool _handlesClick; + public bool Clicked; + public ClickRecorder(bool handlesClick) => _handlesClick = handlesClick; + public override bool HandlesClick => _handlesClick; + public override bool OnEvent(in UiEvent e) + { + if (e.Type == UiEventType.Click) { Clicked = true; return true; } + return false; + } + } + + [Fact] + public void HandlesClickWidget_insideDraggableWindow_stillEmitsClick() + { + // Regression (paperdoll "Slots" toggle): a HandlesClick widget (e.g. a UiButton) inside a + // whole-window-Draggable frame (the inventory window, IA-12 whole-window-drag) must still + // receive its Click. Before the fix the window-move branch captured the press and OnMouseUp + // returned before emitting Click, so the toggle button did nothing. + var root = new UiRoot { Width = 800, Height = 600 }; + var frame = new UiPanel { Left = 10, Top = 300, Width = 200, Height = 60, Draggable = true }; + var btn = new ClickRecorder(handlesClick: true) { Left = 5, Top = 5, Width = 120, Height = 14 }; + frame.AddChild(btn); + root.AddChild(frame); + + root.OnMouseDown(UiMouseButton.Left, 20, 310); // press over the button (screen rect 15,305..135,319) + root.OnMouseUp(UiMouseButton.Left, 20, 310); // release same spot → Click + Assert.True(btn.Clicked); + } + + [Fact] + public void PlainWidget_insideDraggableWindow_doesNotEmitClick() + { + // Contrast that locks the distinction: a NON-HandlesClick child inside the Draggable frame is + // captured as a whole-window-drag, so no Click is emitted (the frame is draggable by it). + var root = new UiRoot { Width = 800, Height = 600 }; + var frame = new UiPanel { Left = 10, Top = 300, Width = 200, Height = 60, Draggable = true }; + var plain = new ClickRecorder(handlesClick: false) { Left = 5, Top = 5, Width = 120, Height = 14 }; + frame.AddChild(plain); + root.AddChild(frame); + + root.OnMouseDown(UiMouseButton.Left, 20, 310); + root.OnMouseUp(UiMouseButton.Left, 20, 310); + Assert.False(plain.Clicked); + } + private sealed class CoordRecorder : UiElement { public (int x, int y)? Down, Move;