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
+7 -3
View File
@@ -2,7 +2,11 @@
How dragging reorders items on a board. Ported from the pathfinder's document of the same name and rewritten for Lanework's two layouts: the **lane strip** (horizontal, mixed widths via the `width` unit multiplier) and the **card masonry** inside every lane (as many interior columns of standard width as the lane has units, each column stacking independently). The pathfinder wrote its model for columns and kept a "Generalizing to 2D" coda for the day cards could differ in size; in Lanework that day is the first one, so the coda is the present tense and lives inline.
Implementation: `Kanban/UI/Board/DropSlotMath.swift` (pure zone math), `Kanban/UI/Board/MasonryLayout.swift` (`MasonryPlacement`, the resting grid), `Kanban/UI/Board/DragAutoScrollMath.swift` (edge autoscroll geometry), `Kanban/UI/Board/LaneLayoutMath.swift` (the strip's analytic resting layout), and the store's drop commits in `Kanban/LiveStore/BoardStore.swift` (`moveCards`, `copyCards`, `receiveCards`, `receiveLanes`, `restoreByDrag`, `receiveRestoredCards`). Tests in `KanbanTests/DropSlotMathTests.swift`, `KanbanTests/DragAutoScrollMathTests.swift` and `KanbanTests/DragWriteTests.swift`. The drag session itself — gestures, drop delegates, the travelling replica, the badge — is the second half of the same milestone and is named below wherever this document depends on it.
Implementation, in two halves.
**The arithmetic**: `Kanban/UI/Board/DropSlotMath.swift` (pure zone math), `Kanban/UI/Board/MasonryLayout.swift` (`MasonryPlacement`, the resting grid), `Kanban/UI/Board/DragAutoScrollMath.swift` (edge autoscroll geometry), `Kanban/UI/Board/LaneLayoutMath.swift` (the strip's analytic resting layout), and the store's drop commits in `Kanban/LiveStore/BoardStore.swift` (`moveCards`, `copyCards`, `moveLanes`, `receiveCards`, `receiveLanes`, `restoreByDrag`, `receiveRestoredCards`). Tests in `KanbanTests/DropSlotMathTests.swift`, `KanbanTests/DragAutoScrollMathTests.swift` and `KanbanTests/DragWriteTests.swift`.
**The session**: `Kanban/UI/Board/DragPayload.swift` (the exported UTTypes and the pasteboard JSON), `Kanban/UI/Board/DragSession.swift` (the app-wide session state, `DragLocality`, the `CommittedHold`, the watchdog), `Kanban/UI/Board/BoardDrops.swift` (`BoardDropContext`'s shared retargets and commit, plus the three drop delegates and the measured-geometry registry), `Kanban/UI/Board/DragAutoScroller.swift` (the ticking driver), and `Kanban/UI/Board/DragShadow.swift` (the shadow and the multi-drag count badge). The gestures live where the design puts the handles — `LaneView` (the header's `.onDrag`, the card face's, and each lane's drop target) and `TrashLaneView` (a row's). Tests in `KanbanTests/DragSessionTests.swift`; the delegates and gestures are deliberately thin over the tested values.
## The pieces
@@ -28,7 +32,7 @@ Two stability rules on top:
## The lane strip's resting layout is arithmetic
The strip has no scroller and no measured frames worth reading: every lane is always on screen because the window's width divides across the lanes' width units (03-board-ui.md § Layout — full visibility). So the resting layout is a closed-form expression of `standardWidth(stripWidth:totalUnits:gap:)`, `slotWidth(units:standard:gap:)` and the unit counts of the visible lanes *minus the dragged run*`LaneLayoutMath`'s own arithmetic, reused rather than restated (`DropSlotMath.laneExtents`). The first slot starts at `gap`, because the strip's outer margin is one gap wide, exactly as `LaneReorderMath.centre` already reads it.
The strip has no scroller and no measured frames worth reading: every lane is always on screen because the window's width divides across the lanes' width units (03-board-ui.md § Layout — full visibility). So the resting layout is a closed-form expression of `standardWidth(stripWidth:totalUnits:gap:)`, `slotWidth(units:standard:gap:)` and the unit counts of the visible lanes *minus the dragged run*`LaneLayoutMath`'s own arithmetic, reused rather than restated (`DropSlotMath.laneExtents`). The first slot starts at `gap`, because the strip's outer margin is one gap wide — the same origin `LaneLayoutMath.laneIndex` hit-tests against and the session's cursor conversion lands in.
`standard` is **not** recomputed with the dragged lanes removed. It is a function of the board's unit total, and a lane in flight is still a lane on the board — the shadow occupies its units. Recomputing would re-divide the whole strip at pickup and again at release, which is the "motion feeds back into logic" failure this model exists to avoid. For a cross-board arrival the destination board's own `standard` is the one that counts, and the arriving run is measured in the destination's units.
@@ -110,7 +114,7 @@ The commit is the store's, and it is one `performWrite` bracket per gesture what
| --- | --- | --- |
| Within-board card drag | `moveCards(_:toLane:at:)` | `moveItem` per card — same parent degrades to a rank rewrite |
| Within-board ⌥-drag | `copyCards(_:toLane:at:)` | `copyItem` per card, `.fork` stamps |
| Within-board lane drag | `moveLane(_:toIndex:)` | `moveItem`, same-parent reorder |
| Within-board lane drag | `moveLanes(_:toIndex:)` | `moveItem`, same-parent reorder |
| Cross-board cards | `receiveCards(_:operation:toLane:at:)` on the **destination** store | `copyItem` / `moveItem` |
| Cross-board lanes | `receiveLanes(_:operation:at:)` on the **destination** store | `copyItem` + tombstone strip / `moveItem` |
| Trash → live lane, same board | `restoreByDrag(cardID:intoLane:at:)` | `restoreItem` then `moveItem` |