From acdefc2e217c29c8d7933cd36b099ebe672ee228 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 14:26:53 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20D.5.3/B.1=20=E2=80=94=20dragging=20a?= =?UTF-8?q?=20toolbar=20item=20moved=20the=20window=20instead=20of=20the?= =?UTF-8?q?=20item?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found at visual verification: an occupied UiItemSlot sits inside the Draggable toolbar frame (UiNineSlicePanel.Draggable=true), so UiRoot.OnMouseDown's FindWindow returned the frame and the window-move branch won — press+drag on a slot moved the whole bar instead of picking up the item. The slot wasn't CapturesPointerDrag (that path is for self-driven text-selection and suppresses the BeginDrag promotion), and UiRoot had no path for "a drag-source inside a draggable window." Fix: add UiElement.IsDragSource (virtual, default false); UiItemSlot overrides it to `ItemId != 0` (occupancy-gated). UiRoot.OnMouseDown now prioritizes IsDragSource over window-move — an OCCUPIED slot starts the item drag (promotes to BeginDrag on >3px), an EMPTY slot falls through to the IA-12 whole-window-drag so the bar stays movable by its empty cells / chrome. UiRoot stays item-agnostic (reads only the bool). This REDUCES divergence (occupied cells now drag like retail) within IA-12's umbrella — no new register row. Regression tests reproduce the LIVE topology (slot inside a Draggable frame); the earlier RootWithBoundSlot tests put the slot directly under the root, so they could not catch it. Full suite 493 pass / 0 fail / 2 skip. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/UI/UiElement.cs | 8 ++++ src/AcDream.App/UI/UiItemSlot.cs | 8 ++++ src/AcDream.App/UI/UiRoot.cs | 11 +++++ .../UI/DragDropSpineTests.cs | 42 +++++++++++++++++++ 4 files changed, 69 insertions(+) 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) + } }