Build the drop-slot model and the drop commits — drag & drop, first half

The pathfinder's drag-reorder model, ported and generalized (DRAG-REORDER.md
travels with it, rewritten for lanes, the interior masonry, multi-drag,
cross-board sessions, the re-grounding trio, and the committed-overlay hold):

- DropSlotMath — resting-layout zones from analytic lane arithmetic and the
  pure masonry placement (MasonryLayout now lays out through the same
  MasonryPlacement the drag reads, so geometry cannot drift), span-capped
  triggers sized to the dragged run's future footprint, hysteresis holds with
  the fresh-entry fallback, boundary ties, own-slot no-ops; nil means hold.
- DragAutoScrollMath — the activation bands and velocity ramp, pure.
- The drop commits, one performWrite bracket each: moveCards/copyCards within
  a board (insertion ranks touch only the dragged cards; renumber fallback);
  receiveCards/receiveLanes/receiveRestoredCards on the destination store for
  cross-board copy and ⌘-move with the import-boundary remint, lane copies
  stripping tombstoned cards while moves carry them; restoreByDrag is now
  positional, writing order only when the drop names a new one.

Gestures, sessions, previews, and delegates are the second half.

773 unit tests (87 new since the keyboard grammar).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 20:10:24 -04:00
parent 4035ba7986
commit 21a5a6dbfd
14 changed files with 2641 additions and 55 deletions
+47
View File
@@ -165,6 +165,53 @@ struct RanksTests {
#expect(Ranks.insertionRank(amongVisible: [1024, 1024], at: 2) == 2048)
}
// MARK: - A contiguous run's ranks (multi-drag)
@Test func insertionRanksPlaceARunAtOneSpotInOrder() throws {
let orders = [1024.0, 2048.0, 3072.0]
// Between two siblings: `count` evenly spaced points, strictly inside and ascending.
let interior = try #require(Ranks.insertionRanks(amongVisible: orders, at: 1, count: 3))
#expect(interior == [1280, 1536, 1792])
#expect(interior.first! > orders[0] && interior.last! < orders[1])
#expect(zip(interior, interior.dropFirst()).allSatisfy { $0 < $1 })
// The ends spread whole gaps, ascending, so the run lands as a block.
#expect(Ranks.insertionRanks(amongVisible: orders, at: 3, count: 2) == [4096, 5120])
#expect(Ranks.insertionRanks(amongVisible: orders, at: 0, count: 2) == [-1024, 0])
}
@Test func insertionRanksAgreeWithTheSingleRankTwinForOneItem() {
let orders = [1024.0, 2048.0, 3072.0]
for index in -1...4 {
#expect(Ranks.insertionRanks(amongVisible: orders, at: index, count: 1)
== Ranks.insertionRank(amongVisible: orders, at: index).map { [$0] },
"position \(index)")
}
}
@Test func insertionRanksAreTotalOnEdgeInputs() {
#expect(Ranks.insertionRanks(amongVisible: [], at: 0, count: 3) == [1024, 2048, 3072])
#expect(Ranks.insertionRanks(amongVisible: [1024], at: 99, count: 2) == [2048, 3072])
#expect(Ranks.insertionRanks(amongVisible: [1024], at: -3, count: 2) == [-1024, 0])
// A run of nothing is nothing, not a failure: a drag emptied by a foreign reload cancels
// itself, and the write it would have made is simply empty.
#expect(Ranks.insertionRanks(amongVisible: [1024], at: 0, count: 0) == [])
}
@Test func insertionRanksReportAnExhaustedGapRatherThanInventingOne() {
// The duplicate-order tie and adjacent Doubles are both renumber triggers, exactly as for
// the single-rank twin and a gap that fits one rank need not fit three.
#expect(Ranks.insertionRanks(amongVisible: [1024, 1024], at: 1, count: 2) == nil)
#expect(Ranks.insertionRanks(amongVisible: [1024, 1024.0000000000002], at: 1, count: 1) == nil)
let tight = [1.0, 1.0.nextUp.nextUp]
#expect(Ranks.insertionRanks(amongVisible: tight, at: 1, count: 1) != nil)
#expect(Ranks.insertionRanks(amongVisible: tight, at: 1, count: 4) == nil)
// The ends never exhaust.
#expect(Ranks.insertionRanks(amongVisible: [1024, 1024], at: 2, count: 2) == [2048, 3072])
}
// MARK: - Precision exhaustion renumber, deterministically
@Test func precisionExhaustionThenRenumberIsDeterministic() {