Files
lanework/Kanban/UI/Board/BoardDrops.swift
T
rzen 90cf82d740 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
2026-07-27 20:58:26 -04:00

441 lines
20 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}
}