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;