Implement drag & drop with the locality model

The second half: system drag sessions over phase 1's model, per
DRAG-REORDER.md and 04-interactions.md § Drag & drop.

- Card faces, lane headers, and trash rows drag as NSItemProvider sessions
  (two exported UTTypes, JSON payload in flatten order, plain-text titles as
  the secondary representation) — replacing m4's custom lane-reorder gesture
  and trash drag-out wholesale; the app-wide DragSession carries the members,
  the frozen dragged sizes, the live proposal, and the effective operation.
- Three drop delegates (lane masonry, strip, window fallback), each accepting
  both types and routing internally per the single-target-dispatch rule; the
  cursor is the physical mouse converted to strip space; proposals come from
  DropSlotMath with hysteresis threaded through, and the lane-strip proposal
  clamps in front of the shown trash.
- Locality picks the default — move within a board, copy across, the badge
  tracking live; ⌥ forces copy (ignored on within-board lane drags), ⌘
  forces move; trash rows restore within their board (positional), copy out
  across boards by default, ⌘ forcing the true restore-move.
- N contiguous shadows with reflow keyed on the proposal; the
  committed-overlay hold renders the dropped arrangement until the reload
  echo lands (1.5 s dissolution deadline for refused writes); the
  re-grounding trio: geometry re-derives per render, proposals re-validate
  by liveness at release, an emptied drag cancels itself.
- Edge autoscroll (ticking driver over DragAutoScrollMath, re-targeting per
  step), the mouse-up-gated late-event cleanup, and the polling watchdog —
  the pathfinder's lifecycle traps, ported.
- Store: moveLanes and multi-card restoreByDrag join the one-bracket drop
  commits.

784 unit tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 20:58:26 -04:00
parent f2d9f3ad07
commit 90cf82d740
24 changed files with 2307 additions and 739 deletions
+26 -9
View File
@@ -184,8 +184,8 @@ struct SpanCappedSlotTests {
// MARK: - The lane strip
/// `standard = 100`, `gap = 10`, matching `LaneReorderMathTests`: a 1× slot is 100 wide, a 2× is
/// 210 and a 3× is 320, and the strip's outer margin is one gap, so the first slot starts at 10.
/// `standard = 100`, `gap = 10`: a 1× slot is 100 wide, a 2× is 210 and a 3× is 320, and the strip's
/// outer margin is one gap, so the first slot starts at 10.
@Suite("DropSlotMath ▸ the lane strip")
struct LaneSlotTests {
private let standard: CGFloat = 100
@@ -197,14 +197,14 @@ struct LaneSlotTests {
#expect(extents == [10...110, 120...440, 450...550])
#expect(DropSlotMath.laneExtents(unitCounts: [], standard: standard, gap: gap).isEmpty)
// The centres agree with `LaneReorderMath.centre`, which reads the same layout the two
// must never drift, since the drag's replica offsets from one and its proposal from the
// other.
for index in 0..<3 {
let centre = LaneReorderMath.centre(ofLaneAt: index, unitCounts: [1, 3, 1],
standard: standard, gap: gap)
#expect(centre == (extents[index].lowerBound + extents[index].upperBound) / 2)
// Each extent is exactly what `BoardView` frames that lane at, and they tile with one gap
// between them the strip always exactly fills (03-board-ui.md § Layout).
for (index, units) in [1, 3, 1].enumerated() {
#expect(extents[index].upperBound - extents[index].lowerBound
== LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap))
}
#expect(extents[1].lowerBound - extents[0].upperBound == gap)
#expect(extents[2].lowerBound - extents[1].upperBound == gap)
}
@Test("A dragged run's span is its slots plus the gaps between them")
@@ -250,6 +250,23 @@ struct LaneSlotTests {
#expect(DropSlotMath.laneSlot(cursorX: 200, restingUnits: [], draggedUnits: [1],
standard: standard, gap: gap, current: nil) == 0)
}
/// **The trash is never a landing spot** (04-interactions.md The trash: "no move or paste ever
/// targets the trash"). The quasi-lane consumes one unit while shown, and it is excluded from the
/// slot list by construction so the terminal slot's uncapped reach past the last *real* lane
/// lands in front of the trash column, never in it or past it.
@Test("The end slot stops before the shown trash column, however far the cursor goes")
func theEndSlotClampsInFrontOfTheTrash() {
// Two 1× lanes plus a shown trash: the strip lays out three units, so the trash occupies
// [230, 330]. `restingUnits` names the lanes only.
let restingUnits = [1, 1]
for x: CGFloat in [240, 300, 330, 900, 5000] {
let slot = DropSlotMath.laneSlot(cursorX: x, restingUnits: restingUnits, draggedUnits: [1],
standard: standard, gap: gap, current: 0)
#expect(slot == restingUnits.count,
"a cursor over the trash column at x = \(x) appends after the last real lane")
}
}
}
// MARK: - The masonry's resting grid