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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user