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
+332 -65
View File
@@ -1,24 +1,6 @@
import AppKit
import SwiftUI
// MARK: - The strip's half of the header drag
/// What the strip lends a lane so its **whole title bar** can be the drag surface (03-board-ui.md §
/// Lane, "no separate grip").
///
/// The gesture lives on the header that is where the design puts it but the two things it needs
/// are the strip's: where this lane currently rests (the origin the translation is measured from)
/// and what a release means (a proposal computed against the live lane order, then a write). Both
/// arrive as closures rather than as values because both must be read at *gesture* time, not at
/// body-evaluation time.
@MainActor
struct LaneHeaderDrag {
/// This lane's resting centre in strip coordinates, read the instant the drag begins and frozen
/// for its duration (`LaneReorderSession.startCentre`).
let startCentre: () -> CGFloat
/// Commit the reorder at whatever the current proposal is, and end the session.
let commit: () -> Void
}
// MARK: - LaneView
/// One lane: a title bar and a vertically scrolling masonry of cards (03-board-ui.md § Lane).
@@ -29,9 +11,11 @@ struct LaneHeaderDrag {
/// (`ItemSymbol`) then the title or its quiet "Untitled" placeholder, a quiet secondary
/// card-count badge, and a trailing quiet new-card button. **The whole bar is the drag surface**:
/// a click selects the lane toggling off on a repeat, exactly as empty space does
/// (04-interactions.md § Selection, settled) and movement past a small threshold begins a reorder
/// (`LaneReorderSession`). The one thing carved out of the drag region is the button, which sits in
/// an overlay outside the gesture so a click on it can never be read as the beginning of a drag.
/// (04-interactions.md § Selection, settled) and movement begins a **system drag session**
/// carrying the lane (`DragSession`, DRAG-REORDER.md). The click-versus-drag split is the system's
/// own now: `.onTapGesture` and `.onDrag` coexist, so a hesitant click can never start a drag and a
/// drag can never also select. The one thing carved out of the drag region is the new-card button,
/// which sits in an overlay outside it.
///
/// ### The lane's one context menu
///
@@ -60,10 +44,14 @@ struct LaneView: View {
/// override it (see `BoardView.laneSlot`).
let columns: Int
/// The strip's reorder session, so the header knows whether *it* is the lane in flight.
let reorder: LaneReorderSession
/// This lane's resting slot width the replica's width, so the image under the cursor is the
/// lane at its real on-screen size (03-board-ui.md § Motion: "a faithful, full-size replica").
let slotWidth: CGFloat
let headerDrag: LaneHeaderDrag
/// The board window's drop machinery: the app-wide session, the geometry registry this lane
/// registers its card grid into, and the shared retarget every hover and every autoscroll step
/// goes through (`BoardDropContext`).
let drops: BoardDropContext
/// The strip's rubber band: the lane's empty space is one of its three surfaces, and every card
/// face registers its frame into the same registry (`MarqueeControl`).
@@ -88,6 +76,14 @@ struct LaneView: View {
/// C7 · full-column top edge (03-board-ui.md § Styling Capabilities).
private let bandHeight: CGFloat = 5
/// The lane's drawn height, for the replica. Measured rather than derived, because a lane is as
/// tall as the strip gives it.
@State private var measuredHeight: CGFloat = 0
/// This lane's edge-autoscroll driver one per lane, ticking only while a card session is in
/// flight (`DragAutoScroller`, DRAG-REORDER.md § Edge autoscroll).
@State private var autoScroller = DragAutoScroller()
var body: some View {
// `spacing: 0` and the padding moved inside: the accent band is **full-width** along the
// lane's top edge, so it must sit outside the content inset rather than in it.
@@ -101,6 +97,14 @@ struct LaneView: View {
}
.background(selectionBackground)
.overlay(selectionStroke)
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { measuredHeight = $0 }
// **This lane's drop target**, on the whole body. It accepts *both* board types and routes
// internally card sessions against this lane's masonry zones, lane sessions forwarded to
// the strip's logic because single-target dispatch has no fall-through (DRAG-REORDER.md).
//
// m5-finder-drops: external Finder file sessions join this same target and this same
// routing; the type list grows by `.fileURL` and the delegate by one branch.
.onDrop(of: boardDragTypes, delegate: LaneDropDelegate(context: drops, laneID: lane.id))
}
// MARK: - Header
@@ -110,7 +114,20 @@ struct LaneView: View {
// The bar is the drag surface, so it must be hit-testable across its whole width
// including the empty stretch between the badge and the button.
.contentShape(Rectangle())
.gesture(headerGesture)
// **The header toggles like empty space** (04-interactions.md § Selection, settled): "a
// click on the already-selected lane's header unselects, one lane-click behavior
// everywhere, so a full lane keeps a pointer path out of selection". Hence the same
// `togglesOnRepeat` the empty space passes the two surfaces differ only in where they
// are. `.onTapGesture` beside `.onDrag` is the click-versus-drag split: the system holds
// the drag off until the pointer actually moves, so a click is never a drag.
.onTapGesture {
store.click(
SelectionTarget(id: lane.id, kind: .lane, side: .live),
modifier: .current,
togglesOnRepeat: true
)
}
.onDrag(startLaneDrag, preview: { dragReplica })
.overlay(alignment: .trailing) { newCardButton }
.contextMenu { laneMenu }
// The lane's half of the Style popover. Anchored on the header because that is the
@@ -272,40 +289,106 @@ struct LaneView: View {
.disabled(store.isReadOnly || store.isEditingInline)
}
/// One gesture recognising both halves of 04-interactions.md Selection's click-vs-drag split:
/// "a plain click on the title bar selects the lane; the drag surface engages only on movement".
// MARK: - The lane drag
/// Begins the lane's system drag session (DRAG-REORDER.md; 04-interactions.md Drag and drop).
///
/// `minimumDistance: 0` so the release is seen even when nothing moved that release *is* the
/// click. `.global` coordinates because the strip's own space shifts as siblings reflow under
/// the proposal, and a translation measured against a moving frame is not a pointer delta.
private var headerGesture: some Gesture {
DragGesture(minimumDistance: 0, coordinateSpace: .global)
.onChanged { value in
// Selection stays live under the lock; reordering does not (02 § The lock's scope).
// The focused-editor rule holds a drag off too: a reorder is a board command.
guard !store.isReadOnly, !store.isEditingInline else { return }
if !reorder.isDragging(lane.id) {
guard abs(value.translation.width) > LaneReorderSession.threshold else { return }
reorder.begin(laneID: lane.id, startCentre: headerDrag.startCentre())
}
reorder.update(translation: value.translation.width)
/// **Dragging any member of a multi-selection drags the whole selection**, in board order
/// which is the lane level's flatten order. A lane outside the selection drags alone, standard
/// macOS targeting.
///
/// Refused under the read-only lock and while an inline editor is focused, like every other
/// mutating gesture (02-architecture.md § The lock's scope; 04 Grammar's focused-editor rule).
/// A refusal is an item provider carrying nothing: no session begins, every drop target declines,
/// and the image snaps back.
private func startLaneDrag() -> NSItemProvider {
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
let selection = store.selection
let ids: Set<ItemID> = selection.liveness == .live
&& selection.ids.contains(lane.id)
&& selection.ids.count > 1
? selection.ids
: [lane.id]
let members = store.snapshot.lanes.filter { !$0.isDeleted && ids.contains($0.id) }
guard !members.isEmpty else { return NSItemProvider() }
let root = store.rootURL
let payload = DragPayload(
boardRoot: root,
kind: .lanes,
side: .live,
items: members.map {
DragPayload.Item(
id: $0.id.rawValue,
folder: root.appendingPathComponent($0.id.rawValue, isDirectory: true).path,
title: $0.title.value
)
}
.onEnded { _ in
if reorder.isDragging(lane.id) {
headerDrag.commit()
} else {
// **The header toggles like empty space** (04-interactions.md § Selection,
// settled): "a click on the already-selected lane's header unselects, one
// lane-click behavior everywhere, so a full lane keeps a pointer path out of
// selection". Hence the same `togglesOnRepeat` the empty space passes the two
// surfaces differ only in where they are.
store.click(
SelectionTarget(id: lane.id, kind: .lane, side: .live),
modifier: .current,
togglesOnRepeat: true
)
)
drops.session.beginLanes(
members.map(\.id),
folders: payload.folders,
// The dragged items' own sizes, frozen at drag start the one thing that is
// (03-board-ui.md § Motion).
units: members.map { LaneLayoutMath.displayUnits(of: $0) },
source: store
)
return payload.itemProvider()
}
/// The image travelling under the cursor: **a faithful, full-size replica of the whole lane**,
/// not the strip of title bar that was grabbed (03-board-ui.md § Motion), fanned with ghosts and
/// a count badge for a multi-drag.
///
/// A static rendition rather than a live `LaneView`: a drag image is a snapshot, so it carries no
/// scrolling, no gestures and no geometry observers, and the card list is capped because anything
/// past the lane's height is clipped anyway.
private var dragReplica: some View {
let count = max(1, draggedLaneCount)
return ZStack {
if count > 2 { replicaFace.offset(x: 12, y: 12).opacity(0.45) }
if count > 1 { replicaFace.offset(x: 6, y: 6).opacity(0.7) }
replicaFace
}
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
.padding(12)
}
private var draggedLaneCount: Int {
let selection = store.selection
guard selection.liveness == .live, selection.ids.contains(lane.id) else { return 1 }
return selection.ids.count
}
private var replicaFace: some View {
VStack(alignment: .leading, spacing: 0) {
accentBand
VStack(alignment: .leading, spacing: 8) {
headerContent
VStack(alignment: .leading, spacing: cardSpacing) {
ForEach(renderedCards.prefix(12)) { card in
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
.foregroundStyle(.secondary)
.imageScale(.medium)
Text(card.title.value ?? "Untitled")
.font(.body)
.lineLimit(2)
Spacer(minLength: 0)
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 8).fill(.background.secondary))
}
Spacer(minLength: 0)
}
}
.padding(6)
}
.frame(width: max(slotWidth, 80), height: max(measuredHeight, 120), alignment: .topLeading)
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background))
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
}
// MARK: - Body
@@ -343,10 +426,16 @@ struct LaneView: View {
store: store,
card: card,
registry: marquee.registry,
drops: drops,
openCard: openCard
)
case .placeholder:
NewCardStubView(store: store, openCard: openCard)
case let .shadow(_, height):
// One of the drag's N contiguous shadows, at the dragged card's frozen
// height the run's real footprint, so the drop lands exactly here.
DragShadow(cornerRadius: 8)
.frame(height: height)
}
}
// "Appear/disappear is scale + fade (cards scale from ~0.8 )"
@@ -363,6 +452,30 @@ struct LaneView: View {
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
// The drag's reflow-to-make-room inside the lane, keyed on **this lane's drop proposal**
// and nothing broader (03-board-ui.md § Motion). `MasonryLayout` is a `Layout` over one
// `ForEach` precisely so the round-robin reshuffle animates as positional slides rather
// than as remove/insert blinks (DRAG-REORDER.md § The card masonry).
.animation(Motion.dragReflow(reduced: reduceMotion), value: cardProposal)
// Where this lane's card grid is drawn, in the window's global space the analytic
// resting grid the drop model replays `MasonryPlacement` over. Registered rather than
// re-derived, so the zones and the drawn grid cannot disagree.
.onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { frame in
drops.registry.update(
LaneDropRegistry.Grid(frame: frame, columns: max(1, columns), spacing: cardSpacing),
for: lane.id
)
}
.onDisappear { drops.registry.removeGrid(lane.id) }
// The edge-autoscroll anchor, **inside** the scroll view's content so
// `enclosingScrollView` resolves (`DragAutoScrollAnchor`). Every scroll step re-resolves
// the proposal through the same shared retarget the drop delegate uses, because the
// cursor is stationary while the content moves under it.
.background {
DragAutoScrollAnchor(scroller: autoScroller) {
drops.retargetCards(inLane: lane.id)
}
}
.contentShape(Rectangle())
// Order matters: the two-tap recogniser must be attached first so a double click is not
// consumed as two singles.
@@ -387,22 +500,51 @@ struct LaneView: View {
// space alike" (03-board-ui.md § Lane, settled).
.contextMenu { laneMenu }
}
// The autoscroll driver, **structurally terminated**: a `.task(id:)` keyed on whether a card
// session is in flight at all, so it is cancelled the moment the session ends and
// `DragSession`'s watchdog guarantees that flag clears however the drag finished
// (DRAG-REORDER.md § Edge autoscroll). Within a session, a pointer outside this lane's
// engagement rect simply scrolls nothing.
.task(id: drops.session.isDraggingCards) {
guard drops.session.isDraggingCards else { return }
await autoScroller.run()
}
}
/// What the masonry lays out: the rendered cards, plus the new-card placeholder when this lane
/// is the one being created into.
/// Where a card drag would land **in this lane**, or `nil` when the proposal is elsewhere the
/// shadow run's position, and the reflow's narrow animation key.
private var cardProposal: Int? {
drops.session.laneProposal(onBoardRooted: store.rootURL, laneID: lane.id)
}
/// What the masonry lays out: the rendered cards, the drag's N contiguous shadows at the
/// proposal, and the new-card placeholder when this lane is the one being created into.
///
/// The overlay is inserted **at the position the card will actually take** after its anchor
/// The placeholder is inserted **at the position the card will actually take** after its anchor
/// for N's "immediately after it", at the bottom otherwise by asking the very function the
/// commit uses to compute the rank (`BoardStore.insertionIndex`). One answer, so the pseudo-card
/// cannot appear anywhere but where the real card lands.
/// cannot appear anywhere but where the real card lands. Both insertions are computed against
/// `renderedCards`, and the placeholder's is shifted past a shadow run that opened in front of
/// it, so neither displaces the other.
private var slots: [LaneSlot] {
var result = renderedCards.map(LaneSlot.card)
guard let placeholder = store.transient.newCardPlaceholder, placeholder.laneID == lane.id else {
return result
let shadowPosition = cardProposal.map { min(max(0, $0), result.count) }
var placeholderPosition: Int?
if let placeholder = store.transient.newCardPlaceholder, placeholder.laneID == lane.id {
placeholderPosition = BoardStore.insertionIndex(after: placeholder.anchorCardID, among: renderedCards)
?? result.count
}
let heights = drops.session.cardHeights
if let shadowPosition {
let shadows = heights.enumerated().map { LaneSlot.shadow(index: $0.offset, height: $0.element) }
result.insert(contentsOf: shadows, at: shadowPosition)
}
if var position = placeholderPosition {
if let shadowPosition, position >= shadowPosition { position += heights.count }
result.insert(.placeholder, at: min(position, result.count))
}
let position = BoardStore.insertionIndex(after: placeholder.anchorCardID, among: renderedCards)
result.insert(.placeholder, at: position ?? result.count)
return result
}
@@ -411,10 +553,17 @@ struct LaneView: View {
/// liveness). The lane half of that rule is `BoardView`'s, which never builds a `LaneView` for a
/// tombstoned lane at all.
///
/// **A dragged card renders nowhere either, for as long as the session lasts.** It is lifted out
/// of the resting layout at pickup and stays out until release *whatever the effective operation
/// is* a -copy's originals really do stay, but can be pressed and released mid-drag, and a
/// layout that re-admitted them on every flip would flap the board under the cursor
/// (DRAG-REORDER.md § Resting-layout zones). The copy's originals reappear when the write lands.
///
/// This is also the collection m5's search filter narrows, which is what keeps the count badge
/// honest for free see `countBadge`.
private var renderedCards: [Card] {
lane.cards.filter { !$0.isDeleted }
let hidden = drops.session.hiddenMembers(onBoardRooted: store.rootURL)
return lane.cards.filter { !$0.isDeleted && !hidden.contains($0.id) }
}
// MARK: - Selection
@@ -464,6 +613,8 @@ struct LaneView: View {
private enum LaneSlot: Identifiable {
case card(Card)
case placeholder
/// One of a drag's N contiguous shadows, at the dragged card's frozen height.
case shadow(index: Int, height: CGFloat)
var id: String {
switch self {
@@ -471,6 +622,9 @@ private enum LaneSlot: Identifiable {
// Constant, because there is only ever one placeholder in one lane at a time and it must
// keep its identity and therefore its keyboard focus while the user types.
case .placeholder: "placeholder"
// Constant per position in the run, so the shadows animate as slides when the proposal moves
// rather than blinking out and back in.
case let .shadow(index, _): "shadow:\(index)"
}
}
@@ -518,6 +672,10 @@ private struct CardFaceView: View {
/// here and takes it out again when it leaves see `View.marqueeTarget`.
let registry: MarqueeTargetRegistry
/// The board window's drop machinery: this face registers its measured height into the geometry
/// registry (the resting grid's input) and starts the card drag session from `.onDrag`.
let drops: BoardDropContext
let openCard: (ItemID) -> Void
/// The app-wide quick-style recents see `LaneView`'s own note.
@@ -572,6 +730,17 @@ private struct CardFaceView: View {
guard ClickModifier.current == .plain else { return }
openCard(card.id)
})
// **The whole face is the drag surface** (04-interactions.md Drag and drop). `.onDrag`
// beside the tap recognisers above is the click-versus-drag split, the system's own: it holds
// the session off until the pointer really moves, so selecting and opening stay instant.
.onDrag(startCardDrag, preview: { dragReplica })
// The card's height, for the drop model's analytic resting grid. A height is content-driven
// and does not animate under the reflow only positions do, and those are never measured
// (`LaneDropRegistry`).
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in
drops.registry.update(height: height, for: card.id)
}
.onDisappear { drops.registry.removeHeight(card.id) }
.marqueeTarget(card.id, kind: .card, side: .live, in: registry)
.contextMenu { cardMenu }
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
@@ -579,6 +748,104 @@ private struct CardFaceView: View {
}
}
// MARK: - The card drag
/// Begins the card's system drag session (DRAG-REORDER.md; 04-interactions.md Drag and drop).
///
/// **Dragging any member of a multi-selection drags the whole selection**, in **flatten order**
/// "lane `order` first, then card `order`", `SelectionGrammar.liveCards`' single definition of
/// it, which is also the order the drop inserts in. A card outside the selection drags alone.
///
/// Refused under the read-only lock and while an inline editor is focused, like every other
/// mutating gesture; a refusal is an item provider carrying nothing, so no session begins.
private func startCardDrag() -> NSItemProvider {
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
let snapshot = store.snapshot
let selection = store.selection
let ids: Set<ItemID> = selection.liveness == .live
&& selection.ids.contains(card.id)
&& selection.ids.count > 1
? selection.ids
: [card.id]
// Flatten order, and the lane each member currently lives in the folder path's middle
// component.
var lanesByCard: [ItemID: ItemID] = [:]
var titles: [ItemID: String?] = [:]
for lane in snapshot.lanes where !lane.isDeleted {
for member in lane.cards where !member.isDeleted && ids.contains(member.id) {
lanesByCard[member.id] = lane.id
titles[member.id] = member.title.value
}
}
let ordered = SelectionGrammar.liveCards(in: snapshot).filter { ids.contains($0) }
guard !ordered.isEmpty else { return NSItemProvider() }
let root = store.rootURL
let payload = DragPayload(
boardRoot: root,
kind: .cards,
side: .live,
items: ordered.compactMap { id in
guard let laneID = lanesByCard[id] else { return nil }
return DragPayload.Item(
id: id.rawValue,
folder: root
.appendingPathComponent(laneID.rawValue, isDirectory: true)
.appendingPathComponent(id.rawValue, isDirectory: true)
.path,
title: titles[id] ?? nil
)
}
)
drops.session.beginCards(
ordered,
folders: payload.folders,
// The dragged items' sizes, frozen at drag start the pickup transition scales the
// replica, and its lingering "last measured frame" would mis-size the shadow and the
// span-cap (03-board-ui.md § Motion).
heights: ordered.map { drops.registry.heights[$0] ?? LaneDropRegistry.nominalCardHeight },
side: .live,
source: store
)
return payload.itemProvider()
}
/// The image travelling under the cursor: this card's face at its real size, fanned with ghosts
/// and a count badge when the whole multi-selection rides along (03-board-ui.md § Motion).
private var dragReplica: some View {
let count = store.selection.liveness == .live && store.selection.ids.contains(card.id)
? max(1, store.selection.ids.count)
: 1
return ZStack {
if count > 2 { replicaFace.offset(x: 10, y: 10).opacity(0.45) }
if count > 1 { replicaFace.offset(x: 5, y: 5).opacity(0.7) }
replicaFace
}
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
.padding(12)
}
/// A static rendition of the face a drag image is a snapshot, so it carries no gestures, no
/// editor and no geometry observers.
private var replicaFace: some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
.foregroundStyle(iconTint)
.imageScale(.medium)
Text(card.title.value ?? "Untitled")
.font(.body)
.lineLimit(4)
.frame(maxWidth: .infinity, alignment: .leading)
attachmentsIndicator
}
.padding(10)
.padding(.leading, stripeWidth)
.frame(width: 220, alignment: .leading)
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
.overlay(alignment: .leading) { accentStripe }
}
// MARK: - Context menu
/// Open, Rename, Style, the quick-style recents row, Delete (11-command-nexus.md Context