diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs
index 04f37772..84e5f8bd 100644
--- a/src/AcDream.App/UI/UiElement.cs
+++ b/src/AcDream.App/UI/UiElement.cs
@@ -113,6 +113,14 @@ public abstract class UiElement
/// drag-drop candidacy is suppressed in favour of the element's own handling.
public bool CapturesPointerDrag { get; set; }
+ /// If true, a left-press-and-move on this element starts a DRAG-DROP
+ /// ( 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 (a self-driven
+ /// interior drag like text selection, which does NOT promote to BeginDrag). Default
+ /// false; overridden by drag sources (e.g. an occupied ).
+ public virtual bool IsDragSource => 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/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs
index 21cbc11f..bae35fe4 100644
--- a/src/AcDream.App/UI/UiItemSlot.cs
+++ b/src/AcDream.App/UI/UiItemSlot.cs
@@ -68,6 +68,14 @@ public sealed class UiItemSlot : UiElement
public override (uint tex, int w, int h)? GetDragGhost()
=> ItemId != 0 && IconTexture != 0 ? (IconTexture, (int)Width, (int)Height) : null;
+ /// An OCCUPIED slot is a drag source — a press-and-move picks up the item
+ /// rather than moving the toolbar window. An EMPTY slot is NOT a drag source, so a
+ /// press-and-move there falls through to the IA-12 whole-window-drag, keeping the bar
+ /// movable by its empty cells / chrome. Drives 's mousedown
+ /// window-vs-item disambiguation (retail moves the window via a dragbar, never cells;
+ /// our whole-window-drag approximation reconciles by gating on occupancy).
+ public override bool IsDragSource => ItemId != 0;
+
/// Walk up to the containing (the drop handler owner).
private UiItemList? FindList()
{
diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs
index e1a54c24..267b5688 100644
--- a/src/AcDream.App/UI/UiRoot.cs
+++ b/src/AcDream.App/UI/UiRoot.cs
@@ -260,6 +260,17 @@ public sealed class UiRoot : UiElement
_resizeMouseX = x; _resizeMouseY = y;
_dragCandidate = false;
}
+ else if (target.IsDragSource)
+ {
+ // A drag SOURCE (e.g. an occupied item cell) inside a Draggable window
+ // starts an item drag-drop, NOT a window move. UiRoot stays item-agnostic:
+ // it only reads the IsDragSource flag (the cell decides occupancy). The
+ // BeginDrag promotion happens on the >3px move (and cancels if the source's
+ // GetDragPayload() returns null). Empty cells are NOT drag sources, so they
+ // fall through to window.Draggable below (IA-12 whole-window-drag), keeping
+ // the bar movable by its empty cells / chrome.
+ _dragCandidate = true;
+ }
else if (target.CapturesPointerDrag)
{
// The pressed widget owns interior drags (e.g. text selection):
diff --git a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
index 26719d1f..175c6e56 100644
--- a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
+++ b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
@@ -203,4 +203,46 @@ public class DragDropSpineTests
list.Cell.OnEvent(new UiEvent(0u, list.Cell, UiEventType.DragEnter, Payload: SomePayload()));
Assert.Equal(UiItemSlot.DragAcceptState.Reject, list.Cell.DragAcceptVisual);
}
+
+ // ── item drag inside a Draggable window (the LIVE toolbar topology) ──────
+ // Regression (visual gate 2026-06-20): the slot sits inside the Draggable toolbar
+ // frame, so FindWindow returns the frame. An OCCUPIED slot must start an ITEM drag
+ // (IsDragSource), NOT move the window; an EMPTY slot falls through to whole-window
+ // drag (IA-12) so the bar stays movable by its empty cells / chrome. The earlier
+ // RootWithBoundSlot tests put the slot directly under the root (no draggable
+ // ancestor), so they could not catch this.
+ private static (UiRoot root, UiPanel frame, UiItemList list) DraggableFrameWithSlot(uint itemId)
+ {
+ var root = new UiRoot { Width = 800, Height = 600 };
+ var frame = new UiPanel { Left = 10, Top = 300, Width = 200, Height = 60, Draggable = true };
+ var list = new UiItemList(_ => (1u, 1, 1)) { Left = 5, Top = 5, Width = 32, Height = 32 };
+ list.Cell.Width = 32; list.Cell.Height = 32;
+ if (itemId != 0) list.Cell.SetItem(itemId, 0x99u);
+ frame.AddChild(list);
+ root.AddChild(frame);
+ return (root, frame, list);
+ }
+
+ [Fact]
+ public void OccupiedSlotInsideDraggableWindow_armsItemDrag_doesNotMoveWindow()
+ {
+ var (root, frame, list) = DraggableFrameWithSlot(0x5001u);
+ // Slot screen rect = frame(10,300)+list(5,5) → (15,305)..(47,337). Press inside, drag >3px.
+ root.OnMouseDown(UiMouseButton.Left, 20, 310);
+ root.OnMouseMove(40, 310);
+ Assert.Same(list.Cell, root.DragSource); // item drag armed
+ Assert.Equal(10f, frame.Left); // window did NOT move
+ Assert.Equal(300f, frame.Top);
+ }
+
+ [Fact]
+ public void EmptySlotInsideDraggableWindow_movesWindow_notItemDrag()
+ {
+ var (root, frame, _) = DraggableFrameWithSlot(0u); // empty slot → not a drag source
+ root.OnMouseDown(UiMouseButton.Left, 20, 310);
+ root.OnMouseMove(40, 310);
+ Assert.Null(root.DragSource); // no item drag
+ Assert.Equal(30f, frame.Left); // window moved (offX=20-10=10; new Left=40-10=30)
+ Assert.Equal(300f, frame.Top); // y unchanged (310-10=300)
+ }
}