fix(ui): D.5.3/B.1 — dragging a toolbar item moved the window instead of the item

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-20 14:26:53 +02:00
parent 1e53820dd7
commit acdefc2e21
4 changed files with 69 additions and 0 deletions

View file

@ -113,6 +113,14 @@ public abstract class UiElement
/// drag-drop candidacy is suppressed in favour of the element's own handling.</summary> /// drag-drop candidacy is suppressed in favour of the element's own handling.</summary>
public bool CapturesPointerDrag { get; set; } public bool CapturesPointerDrag { get; set; }
/// <summary>If true, a left-press-and-move on this element starts a DRAG-DROP
/// (<see cref="UiRoot"/> 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 <see cref="CapturesPointerDrag"/> (a self-driven
/// interior drag like text selection, which does NOT promote to BeginDrag). Default
/// false; overridden by drag sources (e.g. an occupied <see cref="UiItemSlot"/>).</summary>
public virtual bool IsDragSource => false;
/// <summary>Minimum size enforced while resizing.</summary> /// <summary>Minimum size enforced while resizing.</summary>
public float MinWidth { get; set; } = 40f; public float MinWidth { get; set; } = 40f;
public float MinHeight { get; set; } = 40f; public float MinHeight { get; set; } = 40f;

View file

@ -68,6 +68,14 @@ public sealed class UiItemSlot : UiElement
public override (uint tex, int w, int h)? GetDragGhost() public override (uint tex, int w, int h)? GetDragGhost()
=> ItemId != 0 && IconTexture != 0 ? (IconTexture, (int)Width, (int)Height) : null; => ItemId != 0 && IconTexture != 0 ? (IconTexture, (int)Width, (int)Height) : null;
/// <summary>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 <see cref="UiRoot"/>'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).</summary>
public override bool IsDragSource => ItemId != 0;
/// <summary>Walk up to the containing <see cref="UiItemList"/> (the drop handler owner).</summary> /// <summary>Walk up to the containing <see cref="UiItemList"/> (the drop handler owner).</summary>
private UiItemList? FindList() private UiItemList? FindList()
{ {

View file

@ -260,6 +260,17 @@ public sealed class UiRoot : UiElement
_resizeMouseX = x; _resizeMouseY = y; _resizeMouseX = x; _resizeMouseY = y;
_dragCandidate = false; _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) else if (target.CapturesPointerDrag)
{ {
// The pressed widget owns interior drags (e.g. text selection): // The pressed widget owns interior drags (e.g. text selection):

View file

@ -203,4 +203,46 @@ public class DragDropSpineTests
list.Cell.OnEvent(new UiEvent(0u, list.Cell, UiEventType.DragEnter, Payload: SomePayload())); list.Cell.OnEvent(new UiEvent(0u, list.Cell, UiEventType.DragEnter, Payload: SomePayload()));
Assert.Equal(UiItemSlot.DragAcceptState.Reject, list.Cell.DragAcceptVisual); 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)
}
} }