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
+116
View File
@@ -0,0 +1,116 @@
import AppKit
import QuartzCore
import SwiftUI
/// Drives edge-autoscroll for one lane's scroll view across one drag session the *driver* half of
/// DRAG-REORDER.md § Edge autoscroll, whose geometry is `DragAutoScrollMath`.
///
/// Three constraints shape it, and each is a rule from that section:
///
/// - **The pointer is the physical mouse.** Partly for the general animation-proof reason, but mostly
/// because drop callbacks only arrive while the mouse *moves*, and holding still against an edge is
/// exactly the gesture that must keep scrolling. A ticking task plus `NSEvent.mouseLocation` needs
/// no events at all.
/// - **Every scroll step re-resolves the proposal.** The cursor is stationary in the lane's space
/// while the *content* moves under it, so without this the shadow would freeze at whatever slot the
/// last mouse movement proposed and the drop would land there. `didScroll` is that callback, and it
/// goes through the same `BoardDropContext.retargetCards(inLane:)` the lane's drop delegate uses
/// one shared retarget, so the two can never disagree.
/// - **Termination is structural.** The driver is owned by a `.task(id:)` keyed on the session, so it
/// is cancelled the moment the session ends and `DragSession`'s watchdog guarantees that flag
/// clears no matter how the drag finished.
@MainActor
final class DragAutoScroller {
/// A view living inside the scroll view's *content*: its `enclosingScrollView` is the scroller to
/// drive, and its window converts the physical cursor. Weak the view belongs to the hierarchy.
fileprivate weak var anchor: NSView?
/// Invoked after every scroll step. Re-resolves the drop proposal; see the type's note.
fileprivate var didScroll: (() -> Void)?
private var lastTick: CFTimeInterval?
nonisolated init() {}
/// Ticks at display rate until the owning task is cancelled.
func run() async {
lastTick = nil
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(16))
guard !Task.isCancelled else { break }
step()
}
lastTick = nil
}
private func step() {
let now = CACurrentMediaTime()
let elapsed = min(max(now - (lastTick ?? now), 0), 0.05)
lastTick = now
guard elapsed > 0,
let anchor,
let window = anchor.window,
let scrollView = anchor.enclosingScrollView
else { return }
let clip = scrollView.contentView
let visible = clip.bounds
guard visible.width > 0, visible.height > 0 else { return }
// Physical cursor window the clip view's (scrolled) coordinates, then relative to the
// visible area's top-left corner, which is the space `DragAutoScrollMath` is written in.
let inWindow = window.convertPoint(fromScreen: NSEvent.mouseLocation)
let inClip = clip.convert(inWindow, from: nil)
let pointer = CGPoint(x: inClip.x - visible.minX, y: inClip.y - visible.minY)
guard DragAutoScrollMath.engagementRect(viewport: visible.size).contains(pointer) else { return }
let velocity = DragAutoScrollMath.velocity(pointer: pointer, viewport: visible.size)
guard velocity.dx != 0 || velocity.dy != 0 else { return }
let document = scrollView.documentView?.frame ?? .zero
var proposed = visible
proposed.origin.x = DragAutoScrollMath.nextOffset(
current: visible.minX, velocity: velocity.dx, elapsed: CGFloat(elapsed),
minOffset: document.minX, maxOffset: document.maxX - visible.width)
proposed.origin.y = DragAutoScrollMath.nextOffset(
current: visible.minY, velocity: velocity.dy, elapsed: CGFloat(elapsed),
minOffset: document.minY, maxOffset: document.maxY - visible.height)
// The clip view has the final say (content insets, magnification).
let target = clip.constrainBoundsRect(proposed).origin
guard abs(target.x - visible.minX) > 0.01 || abs(target.y - visible.minY) > 0.01 else { return }
clip.scroll(to: target)
scrollView.reflectScrolledClipView(clip)
didScroll?()
}
}
/// Binds a `DragAutoScroller` to the scroll view it should drive.
///
/// **Must be placed inside the scroll view's content** (as its `.background`), so
/// `enclosingScrollView` resolves; a background on the `ScrollView` itself sits outside the clip view
/// and would find nothing.
struct DragAutoScrollAnchor: NSViewRepresentable {
let scroller: DragAutoScroller
/// Refreshed on every view update, so the callback always closes over the current context rather
/// than the one the session started with.
let didScroll: () -> Void
func makeNSView(context: Context) -> NSView {
let view = NSView()
bind(view)
return view
}
func updateNSView(_ view: NSView, context: Context) {
bind(view)
}
private func bind(_ view: NSView) {
scroller.anchor = view
scroller.didScroll = didScroll
}
}