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