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
+125
View File
@@ -0,0 +1,125 @@
import CoreGraphics
/// Edge-autoscroll geometry for a scroll view hosting drop targets, as pure arithmetic no view,
/// no timer, no `NSScrollView` (`DragAutoScrollMathTests`). Ported from the pathfinder, whose
/// numbers are what was proven; the reasoning is reproduced because the behaviour is.
///
/// A lane's cards live in a scroll view, so a lane taller than its viewport has landing spots below
/// the fold and nothing in `DropSlotMath` can reach them, since the proposal is a function of the
/// cursor over the *visible* resting layout. A card session hovering near either end of a lane's
/// scroll area therefore scrolls it, continuously, until the pointer leaves the band or the drag
/// ends (DRAG-REORDER.md § Edge autoscroll).
///
/// ## The geometry
///
/// Along each axis the visible area owns an **activation band** of `band` points at either end. A
/// pointer inside a band scrolls that way at a speed that ramps with how deep into the band it
/// sits: `minSpeed` at the band's inner edge, `maxSpeed` at (and beyond) the visible area's own
/// edge. Outside both bands the velocity is exactly zero, so a drag that merely crosses the middle
/// of a lane never scrolls it.
///
/// The `minSpeed` floor is deliberate: entering a band produces immediate, visible motion instead
/// of an imperceptible crawl that leaves the user wondering whether autoscroll exists at all. It is
/// the one discontinuity in the ramp, and it sits exactly on the band boundary, where the pointer
/// is moving anyway.
///
/// The pointer may also sit *outside* the visible area and still drive it generously above and
/// below (the lane's header and the strip's padding are still "this lane"), but barely sideways, so
/// a drag over the neighbouring lane never scrolls this one. `engagementRect` is that reach; a
/// pointer outside it drives nothing.
///
/// Everything is axis-agnostic: the board strip has nothing to autoscroll today (every lane shares
/// the window width and the strip fills the window height 03-board-ui.md § Layout), and the same
/// math would serve one unchanged if that ever changes.
///
/// The ticking driver the physical-mouse read, the re-resolved proposal on every step, the
/// structurally terminated task is the drag session's, not this file's.
enum DragAutoScrollMath {
/// Thickness of the activation band at each end of the visible area.
static let band: CGFloat = 56
/// Speed at the band's inner edge the floor described above, in points/second.
static let minSpeed: CGFloat = 90
/// Speed at (and beyond) the visible area's own edge, in points/second. Deliberately not
/// faster: every scroll step re-resolves the drop proposal against the lane's resting grid, and
/// the distance the content travels between two resolutions is this speed divided by the tick
/// rate.
static let maxSpeed: CGFloat = 800
/// How far above the visible area the pointer may sit and still drive it enough to cover the
/// lane's header, which is where a drag naturally goes to scroll up.
static let reachAbove: CGFloat = 48
/// The same below, covering the lane's bottom padding.
static let reachBelow: CGFloat = 24
/// The sideways reach kept under half the distance between two lanes' scroll areas so only
/// one lane ever engages.
static let reachSide: CGFloat = 12
/// The region in the visible area's own coordinates, `(0, 0)` at its top-left a pointer
/// must be in to drive this scroller at all.
static func engagementRect(viewport: CGSize) -> CGRect {
CGRect(x: -reachSide,
y: -reachAbove,
width: viewport.width + reachSide * 2,
height: viewport.height + reachAbove + reachBelow)
}
/// Signed scroll velocity in points/second for a pointer at `position` along an axis whose
/// visible extent runs `0...length`: negative scrolls toward the start (content moves
/// down/right), positive toward the end.
///
/// `band` is clamped to half the extent, so the two bands of a short viewport meet rather than
/// overlap and its exact centre still resolves to "no scrolling".
static func velocity(position: CGFloat,
length: CGFloat,
band: CGFloat = band,
minSpeed: CGFloat = minSpeed,
maxSpeed: CGFloat = maxSpeed) -> CGFloat {
guard length > 0 else { return 0 }
let band = min(band, length / 2)
guard band > 0 else { return 0 }
let depth: CGFloat
let direction: CGFloat
if position < band {
depth = (band - position) / band
direction = -1
} else if position > length - band {
depth = (position - (length - band)) / band
direction = 1
} else {
return 0
}
return direction * (minSpeed + (maxSpeed - minSpeed) * min(max(depth, 0), 1))
}
/// Both axes at once for a pointer in the visible area's own coordinates.
static func velocity(pointer: CGPoint,
viewport: CGSize,
band: CGFloat = band,
minSpeed: CGFloat = minSpeed,
maxSpeed: CGFloat = maxSpeed) -> CGVector {
CGVector(
dx: velocity(position: pointer.x, length: viewport.width,
band: band, minSpeed: minSpeed, maxSpeed: maxSpeed),
dy: velocity(position: pointer.y, length: viewport.height,
band: band, minSpeed: minSpeed, maxSpeed: maxSpeed)
)
}
/// One tick's scroll offset: `current` advanced by `velocity` for `elapsed` seconds, clamped
/// into the scrollable range. An empty or inverted range (content shorter than the viewport)
/// pins to `minOffset`.
static func nextOffset(current: CGFloat,
velocity: CGFloat,
elapsed: CGFloat,
minOffset: CGFloat,
maxOffset: CGFloat) -> CGFloat {
let upper = max(minOffset, maxOffset)
return min(max(current + velocity * elapsed, minOffset), upper)
}
}