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
+440
View File
@@ -0,0 +1,440 @@
import AppKit
import SwiftUI
import UniformTypeIdentifiers
// MARK: - What each lane draws, as the drag reads it
/// Where each lane's card grid is drawn and how tall its cards are the measured half of the card
/// masonry's drop geometry, one registry per board window.
///
/// **Deliberately not `@Observable`.** Nothing renders off it: it exists so a drop delegate and the
/// autoscroll driver can ask, at *event* time, where the grid is and what the resting row extents
/// are. Observing it would invalidate the strip on every layout pass, which is the animation
/// feedback loop this whole model exists to avoid.
///
/// ### What is measured here, and why that is animation-proof
///
/// 03-board-ui.md § Motion forbids reading *mid-flight* measurements. Two things are read here and
/// neither is one:
///
/// - **The grid's frame.** A lane's card area does not move while a card session is in flight: the
/// masonry reflows *inside* it, and the strip only reflows for a lane session, which reads none of
/// this.
/// - **Each card's height.** A card's height is content-driven the column width is fixed by the
/// lane's unit count so it does not animate under the reflow; only positions do. The positions
/// are never measured: they are replayed analytically from these heights through
/// `MasonryPlacement.frames(heights:)`, which is the very function `MasonryLayout` places with
/// (DRAG-REORDER.md § The card masonry).
///
/// The one input that *is* frozen at drag start is the **dragged** cards' own heights, which live on
/// `DragSession`: the pickup transition scales the replica and corrupts its last measured frame.
@MainActor
final class LaneDropRegistry {
/// One lane's masonry, as drawn.
struct Grid: Equatable, Sendable {
/// The card area's frame in the window's SwiftUI global space the space the physical
/// cursor is converted into (`BoardDropContext.globalCursor`).
var frame: CGRect
/// Interior masonry columns the lane's width units.
var columns: Int
/// Spacing between columns and between stacked cards.
var spacing: CGFloat
}
/// The height a card with no registered measurement is assumed to have a lane whose faces have
/// not laid out yet. Nominal rather than zero, so the resting rows still tile.
static let nominalCardHeight: CGFloat = 44
/// The board strip's own frame in the window's SwiftUI global space the origin the strip
/// coordinates `DropSlotMath.laneExtents` is written in are measured from. It lives here rather
/// than in the view's `@State` so a delegate reads the *current* rectangle at event time.
var stripFrame: CGRect = .zero
private(set) var grids: [ItemID: Grid] = [:]
private(set) var heights: [ItemID: CGFloat] = [:]
func update(_ grid: Grid, for laneID: ItemID) { grids[laneID] = grid }
func removeGrid(_ laneID: ItemID) { grids.removeValue(forKey: laneID) }
func update(height: CGFloat, for cardID: ItemID) { heights[cardID] = height }
func removeHeight(_ cardID: ItemID) { heights.removeValue(forKey: cardID) }
}
// MARK: - The board window's half of a drop
/// Everything a drop delegate and the autoscroll driver needs to answer "where would this land",
/// read at **event** time rather than captured at body-evaluation time.
///
/// The closures are the point: a captured snapshot of the strip's frame or its standard width goes
/// stale the moment the layout animates, and two delegates holding different snapshots would flap
/// the proposal between them.
@MainActor
struct BoardDropContext {
let store: BoardStore
let session: DragSession
let registry: LaneDropRegistry
/// The strip's inter-lane gap, which is also its outer margin.
let gap: CGFloat
/// The window hosting this board the physical cursor is converted through it.
let window: @MainActor () -> NSWindow?
/// The strip's frame in the window's SwiftUI global space.
let stripFrame: @MainActor () -> CGRect
/// The strip's 1× lane width for the current drag context (`LaneLayoutMath.standardWidth`).
let standard: @MainActor () -> CGFloat
// MARK: The cursor
/// The physical cursor in the window's SwiftUI global space.
///
/// **`NSEvent.mouseLocation`, never `DropInfo.location`** (DRAG-REORDER.md § Animation-proof
/// inputs): the drop callback's location is expressed in the target view's space, and that view
/// may itself be mid-reflow. This is also what `LaneResizeSession` and `MarqueeSession` already
/// read.
func globalCursor() -> CGPoint? {
guard let window = window(), let content = window.contentView else { return nil }
let inWindow = window.convertPoint(fromScreen: NSEvent.mouseLocation)
let inContent = content.convert(inWindow, from: nil)
// SwiftUI's global space is top-left-origin; an unflipped `NSView` is bottom-left.
let y = content.isFlipped ? inContent.y : content.bounds.height - inContent.y
return CGPoint(x: inContent.x, y: y)
}
/// The cursor in strip coordinates 0 at the strip's leading edge, outer margin included, which
/// is the origin `DropSlotMath.laneExtents` assumes.
func stripCursor() -> CGPoint? {
guard let cursor = globalCursor() else { return nil }
let frame = stripFrame()
return CGPoint(x: cursor.x - frame.minX, y: cursor.y - frame.minY)
}
// MARK: Re-grounding
/// **Rule 2 of the mid-drag re-grounding trio** (04-interactions.md Drag and drop): a proposal
/// whose target lane was tombstoned or vanished in a reload is invalidated tombstoned lanes are
/// never drop targets the shadow withdraws, and no proposal stands until the pointer reaches a
/// live target.
///
/// Run at the top of every callback *and* again at release, against the snapshot as it is then;
/// the store's commits enforce the same rule independently, so the gesture and the write cannot
/// disagree.
func revalidateProposal() {
guard let proposal = session.proposal,
DragLocality.isSameBoard(proposal.boardRoot, store.rootURL),
let laneID = proposal.laneID
else { return }
guard !store.snapshot.lanes.contains(where: { $0.id == laneID && !$0.isDeleted }) else { return }
session.propose(nil)
}
// MARK: Retargeting the one shared answer
/// Where a **lane** session would land on this board's strip.
///
/// The zones are analytic `DropSlotMath.laneExtents` over the remaining lanes' unit counts and
/// this strip's standard width and the cursor is the physical mouse, so neither input is a
/// measured frame (03-board-ui.md § Motion).
///
/// **The terminal slot is clamped before the trash.** The quasi-lane consumes one unit while
/// shown and is never a landing spot for anything (04-interactions.md The trash: "no move or
/// paste ever targets the trash"), so it is absent from the slot list by construction and the end
/// slot's uncapped reach past the last real lane lands *before* it.
func retargetLanes() {
guard session.isDraggingLanes, let cursor = stripCursor() else { return }
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
let resting = store.snapshot.lanes.filter { !$0.isDeleted && !hidden.contains($0.id) }
let restingUnits = resting.map { LaneLayoutMath.displayUnits(of: $0) }
let slot = DropSlotMath.laneSlot(
cursorX: cursor.x,
restingUnits: restingUnits,
draggedUnits: session.laneUnits,
standard: standard(),
gap: gap,
current: session.stripProposal(onBoardRooted: store.rootURL)
)
guard let slot else { return } // a dead region: hold the current proposal
let index = min(max(0, slot), restingUnits.count)
session.propose(DropTarget(boardRoot: store.rootURL, laneID: nil, index: index))
}
/// Where a **card** session would land in `laneID`'s masonry.
///
/// The single answer the lane's own drop delegate, the strip's fall-through, and the autoscroll
/// driver all go through "the lane's drop delegate and the autoscroll driver must go through
/// one shared retarget, so they can never disagree" (DRAG-REORDER.md § Edge autoscroll).
func retargetCards(inLane laneID: ItemID) {
guard session.isDraggingCards, let cursor = globalCursor() else { return }
guard let lane = store.snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) else {
revalidateProposal()
return
}
guard let grid = registry.grids[laneID] else { return }
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
let rendered = lane.cards.filter { !$0.isDeleted && !hidden.contains($0.id) }
let heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight }
let placement = MasonryPlacement(
columnCount: grid.columns,
columnWidth: MasonryPlacement.columnWidth(
totalWidth: grid.frame.width, columnCount: grid.columns, spacing: grid.spacing),
spacing: grid.spacing,
origin: grid.frame.origin
)
let slot = DropSlotMath.cardSlot(
cursor: cursor,
placement: placement,
heights: heights,
// The run's footprint at the landing spot: the first dragged card's frozen height, which
// is the trigger rect the cursor is over (the rest stack below it).
draggedHeight: session.cardHeights.first ?? LaneDropRegistry.nominalCardHeight,
current: session.laneProposal(onBoardRooted: store.rootURL, laneID: laneID)
)
guard let slot else { return } // a dead region: hold
session.propose(DropTarget(boardRoot: store.rootURL, laneID: laneID, index: slot))
}
/// The strip's fall-through for card sessions: which lane is under the cursor, analytically.
///
/// This is both the safety net for a lane whose own drop region goes dead (DRAG-REORDER.md §
/// Single-target dispatch) and the live handler for the strip's own regions. A cursor over a gap,
/// the outer margin, or the trash column is over no lane at all `LaneLayoutMath.laneIndex`
/// answers `nil` there and the proposal simply **holds**, which is the hysteresis contract.
func retargetCardsFromStrip() {
guard session.isDraggingCards, let cursor = stripCursor() else { return }
let lanes = store.snapshot.lanes.filter { !$0.isDeleted }
let index = LaneLayoutMath.laneIndex(
atX: cursor.x,
unitCounts: lanes.map { LaneLayoutMath.displayUnits(of: $0) },
standard: standard(),
gap: gap
)
guard let index, lanes.indices.contains(index) else { return }
retargetCards(inLane: lanes[index].id)
}
/// The retarget for whichever session type is in flight, from the strip's own surfaces.
func retargetFromStrip() {
revalidateProposal()
if session.isDraggingLanes {
retargetLanes()
} else {
retargetCardsFromStrip()
}
}
// MARK: The drop proposal the badge tracks
/// What `dropUpdated` answers: the effective operation while the shadows are on *this* board,
/// `.cancel` otherwise.
///
/// The operation is re-resolved here rather than at pickup, which is what makes the badge track
/// live as the cursor crosses a board boundary (04-interactions.md Drag and drop).
func dropProposal() -> DropProposal {
guard let proposal = session.proposal,
DragLocality.isSameBoard(proposal.boardRoot, store.rootURL)
else { return DropProposal(operation: .cancel) }
let operation = session.resolveOperation(destinationRoot: store.rootURL)
return DropProposal(operation: operation == .copy ? .copy : .move)
}
// MARK: The commit
/// Commits the current proposal **the drop always lands exactly where the shadows show**
/// (DRAG-REORDER.md § The pieces).
///
/// The three re-grounding rules are applied here, against the snapshot as it is *now* rather than
/// against whatever the last render believed:
///
/// 1. the geometry was re-derived on every sample and the proposal is what it produced;
/// 2. a proposal naming a vanished or tombstoned lane is invalidated, and **release with no valid
/// proposal cancels** items return, nothing is written;
/// 3. an emptied drag cancels itself, and a partly emptied one drops the survivors.
///
/// The commit is the **destination** store's, one `performWrite` bracket per gesture whatever the
/// set's size (DRAG-REORDER.md § The drop commits).
func commitDrop() -> Bool {
guard session.isActive, let kind = session.kind, let sourceRoot = session.sourceRoot else {
return false
}
revalidateProposal()
guard let target = session.proposal,
DragLocality.isSameBoard(target.boardRoot, store.rootURL)
else {
cancelDrop()
return false
}
let survivors = session.survivors
guard !survivors.isEmpty else {
cancelDrop()
return false
}
let ids = survivors.map { session.members[$0] }
let folders = survivors.map { session.folders[$0] }
let within = DragLocality.isSameBoard(sourceRoot, store.rootURL)
let operation = session.resolveOperation(destinationRoot: store.rootURL)
switch kind {
case .lanes:
if within {
store.moveLanes(Set(ids), toIndex: target.index)
} else {
store.receiveLanes(folders, operation: operation, at: target.index)
}
case .cards:
guard let laneID = target.laneID else {
cancelDrop()
return false
}
switch (session.side, within) {
case (.live, true):
if operation == .copy {
store.copyCards(Set(ids), toLane: laneID, at: target.index)
} else {
store.moveCards(Set(ids), toLane: laneID, at: target.index)
}
case (.live, false):
store.receiveCards(folders, operation: operation, toLane: laneID, at: target.index)
case (.trashed, true):
// Drag-to-restore, and its twin. "Dropping a tombstoned card into one of its own
// board's lanes restores it at the drop position"; is the copy-out instead a
// live copy lands and the tombstoned original stays (04-interactions.md The trash,
// "C, -drag always yield live copies").
if operation == .copy {
store.receiveRestoredCards(folders, operation: .copy, toLane: laneID, at: target.index)
} else {
store.restoreByDrag(cardIDs: ids, intoLane: laneID, at: target.index)
}
case (.trashed, false):
store.receiveRestoredCards(folders, operation: operation, toLane: laneID, at: target.index)
}
}
// The committed-overlay hold: keep drawing the arrangement until this store's next snapshot.
session.commit(into: store)
return true
}
/// Release with nothing valid to write: the items return and nothing is written.
func cancelDrop() {
withAnimation(Motion.dragReflow(reduced: Motion.prefersReducedMotion)) { session.end() }
}
}
// MARK: - Drop delegates
// **Single-target dispatch** (DRAG-REORDER.md, the constraint of the same name): SwiftUI/macOS
// delivers a drag session to the *deepest* drop region under the cursor with no fall-through, not
// even when that target's declared content types don't match the session's payload. So every
// delegate below accepts **both** board types and routes internally; a region whose topmost target
// understood only one of them would be a dead zone for the other no hover callbacks, and a release
// there would snap back instead of committing.
//
// m5-finder-drops: the next card adds external Finder file sessions (`.fileURL`) to this same
// dispatch files onto a card become attachments, files onto lane empty space become cards
// (04-interactions.md Drag and drop) and the same dead-region rule applies to them, so the type
// list and the routing switch in each delegate below grow by one case rather than gaining a delegate
// of their own.
/// The board types every delegate declares. Spelled once so no target can accidentally accept fewer.
let boardDragTypes: [UTType] = [.laneworkCards, .laneworkLanes]
/// One lane's drop target, attached to the whole lane body.
///
/// **Card sessions** resolve against this lane's masonry zones. **Lane sessions** are forwarded to
/// the strip's logic (cursor converted to strip space by the shared context), so lane reordering
/// keeps working while the cursor crosses lane bodies.
struct LaneDropDelegate: DropDelegate {
// m5-finder-drops: a file session resolves against these same masonry zones onto a card it
// becomes attachments, onto empty space a card per file (04-interactions.md Drag and drop).
let context: BoardDropContext
let laneID: ItemID
func validateDrop(info: DropInfo) -> Bool {
context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
}
func dropEntered(info: DropInfo) { retarget() }
func dropUpdated(info: DropInfo) -> DropProposal? {
retarget()
return context.dropProposal()
}
// No `dropExited`, deliberately: the proposal is meant to **hold** while the cursor leaves for
// ambiguous territory that is the hysteresis contract (DRAG-REORDER.md § Hysteresis).
func performDrop(info: DropInfo) -> Bool {
context.commitDrop()
}
private func retarget() {
context.revalidateProposal()
if context.session.isDraggingLanes {
context.retargetLanes()
} else {
context.retargetCards(inLane: laneID)
}
}
}
/// The strip's drop target the backdrop, the gaps, the outer margin, and the trash column's
/// footprint, which is never a landing spot of its own (04-interactions.md The trash) and so
/// simply falls through to here.
///
/// Lane sessions retarget against the strip's analytic zones; card sessions retarget through the
/// same shared function the lane delegates use, resolving the lane under the cursor analytically
/// the safety net for a lane whose own drop region goes dead.
struct StripDropDelegate: DropDelegate {
// m5-finder-drops: the strip is a file session's safety net too the same dead-region
// hit-testing bug can strand one, and without file support here it would have nowhere to land.
let context: BoardDropContext
func validateDrop(info: DropInfo) -> Bool {
context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
}
func dropEntered(info: DropInfo) { context.retargetFromStrip() }
func dropUpdated(info: DropInfo) -> DropProposal? {
context.retargetFromStrip()
return context.dropProposal()
}
func performDrop(info: DropInfo) -> Bool {
context.commitDrop()
}
}
/// The window-level fallback, behind every specific target: a release over any in-window region they
/// do not cover (the banner strip, the window's edges) commits the current proposal rather than
/// leaking the session into a cancel-snapback. It retargets nothing the drop lands where the
/// shadows already show, which is what the shadows promise.
struct BoardFallbackDropDelegate: DropDelegate {
// m5-finder-drops: a file session released over uncovered window chrome commits whatever the
// file target currently names, exactly as this commits the current card/lane proposal.
let context: BoardDropContext
func validateDrop(info: DropInfo) -> Bool {
context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
}
func dropUpdated(info: DropInfo) -> DropProposal? {
context.dropProposal()
}
func performDrop(info: DropInfo) -> Bool {
context.commitDrop()
}
}