import AppKit import SwiftUI import UniformTypeIdentifiers // MARK: - What each lane draws, as the drag reads it /// Where each lane's card grid and title bar are 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). /// - **Each lane's header frame.** The title bar sits outside the card scroll view and above it, so /// it neither scrolls nor reflows for anything a drop can do; it is read for one rule only — "a /// release on the lane header resolves to the topmost position" (04-interactions.md ▸ Drag and /// drop, settled 2026-07-28), which needs an edge the scrolling masonry cannot supply. /// /// 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 masonry arithmetic this grid is drawn with — `MasonryLayout`'s own expression, so /// the zones and the drawn grid cannot disagree (`MasonryPlacement`). /// /// **Derived at event time and deliberately never cached**, unlike the resting layout it is /// applied to (`RestingLayoutCache`): the grid is registered from inside the lane's scroll /// view, so its origin moves whenever the lane scrolls — which the autoscroll driver does on /// purpose, mid-drag, with the snapshot standing perfectly still. It is also free: four /// stores and a divide, no allocation. var placement: MasonryPlacement { MasonryPlacement( columnCount: columns, columnWidth: MasonryPlacement.columnWidth( totalWidth: frame.width, columnCount: columns, spacing: spacing), spacing: spacing, origin: frame.origin ) } } /// 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. /// /// **Font-derived** (`BoardMetrics.nominalCardHeight`, 10-accessibility.md's full-relative-scaling /// rule), and it matters more here than at most sites: this is the stand-in the drop model tiles /// rows with before anything has measured itself, so a figure fixed at 13pt's 44 points would put /// every un-measured slot boundary in the wrong place at a large system text size — and the drop /// model is forbidden from reading measured frames mid-flight (03-board-ui.md § Motion), so this /// guess is all it has until the lane lays out. @MainActor static var nominalCardHeight: CGFloat { BoardMetrics.nominalCardHeight(bodyPointSize: BoardMetrics.bodyPointSize) } /// 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] = [:] /// How many times a registered card height has actually **changed**, ever — the freshness signal /// `RestingLayoutCache` keys its heights on. /// /// A height is not snapshot content, and that is the whole reason this counter exists: a face /// measuring itself for the first time replaces the `nominalCardHeight` stand-in its lane was /// tiled with, and no store's `snapshotGeneration` moves for it. A layout cached across that /// would keep proposing against the guess. /// /// Bumped only on a real change, so the steady state — a drag over a board that has finished /// laying out, which is every drag after its first frames — leaves it still and the cache holds. private(set) var heightsGeneration = 0 /// Each lane's title bar, in the same global space `Grid.frame` is written in — the topmost-rule /// stripe (`FileDropZones.landing`). Absent for a lane that has not laid its header out yet, and /// the file zones then let the masonry answer alone. private(set) var headers: [ItemID: CGRect] = [:] func update(_ grid: Grid, for laneID: ItemID) { grids[laneID] = grid } func removeGrid(_ laneID: ItemID) { grids.removeValue(forKey: laneID) } func update(header frame: CGRect, for laneID: ItemID) { headers[laneID] = frame } func removeHeader(_ laneID: ItemID) { headers.removeValue(forKey: laneID) } func update(height: CGFloat, for cardID: ItemID) { guard heights[cardID] != height else { return } heights[cardID] = height heightsGeneration += 1 } func removeHeight(_ cardID: ItemID) { guard heights.removeValue(forKey: cardID) != nil else { return } heightsGeneration += 1 } } // MARK: - The resting layout a card drag proposes against /// One lane's **resting layout**, as a card drag's zones read it: the cards standing in it with the /// dragged run already lifted out, and how tall each one is. /// /// **Ids and heights, never `Card` values.** This is what the masonry zones are built over /// (`DropSlotMath.cardSlot`), and all they want from a card is a number; a `[Card]` of it would be /// nine `FieldValue`s and a whole `FrontmatterDocument` of ARC traffic per card, rebuilt on every /// mouse sample of the drag. struct LaneRestingLayout: Equatable, Sendable { /// The lane's cards in logical order with the dragged run lifted out — **the index space every /// proposal in this lane is counted in**, which is the space `BoardStore.moveCards` / /// `copyCards` resolve theirs in too (each writer counted in the layout its own gesture showed). var cardIDs: [ItemID] /// Their heights, aligned 1:1 with `cardIDs`, each one the registered measurement or /// `LaneDropRegistry.nominalCardHeight` for a face that has not laid out yet. var heights: [CGFloat] } /// The per-lane resting layouts a drag is proposing against, built **once per snapshot** rather than /// once per mouse sample. /// /// ### Why this is not a hole in the re-grounding rule /// /// "Geometry re-derives … the resting zones are recomputed against each new snapshot. Nothing is /// cached across a reload because nothing needs to be" (DRAG-REORDER.md § The mid-drag re-grounding /// trio, rule 1). Nothing is cached across a reload here either: an entry stands only for the /// `snapshotGeneration` it was built from, so a foreign reload landing mid-drag retires every layout /// derived from the snapshot before it and the next sample re-derives against the board as it now /// is. What this removes is the *repetition* — the same lane rebuilt from the same snapshot dozens /// of times a second, for samples whose answer `DragSession.propose` then discards as unchanged. /// /// ### Why the applied counter is the right one /// /// **`BoardStore.snapshotGeneration`, not `landedReloads`.** The layout is a function of the /// snapshot's *content*, and the applied counter is exactly "the content changed": a value-equal /// walk lands the same cards in the same order and cannot move a zone, so keying on the walk counter /// would throw the layout away for reloads that provably changed nothing. The committed-overlay hold /// does draw an arrangement the snapshot does not describe while it stands — but it also freezes /// `propose` and `resolveOperation`, so nothing this cache feeds reaches a proposal for as long as /// it does. /// /// ### What the key has to carry, and what it provably need not /// /// Everything the layout derives from that can move mid-drag is in the key: the snapshot /// (`generation`, plus `boardRoot` because a cross-board drag retargets against the *hovered* /// board's store), the lane, the registered heights (`LaneDropRegistry.heightsGeneration`), and the /// dragged run's own hidden set — which follows the **operation**, since ⌥ pressed mid-drag leaves a /// copy's originals standing in the layout (`DragSession.hiddenMembers`). /// /// Two inputs are deliberately absent. The **grid** is not layout content — it is read live at every /// sample (`LaneDropRegistry.Grid.placement`), because a lane that scrolls moves its origin without /// touching any snapshot. And the **dragged run's frozen heights** are frozen at pickup by /// construction (`DragSession.cardHeights`), so they cannot move for the life of an entry — which is /// one drag, since the session clears this at `begin` and at `end`. @MainActor final class RestingLayoutCache { /// Which lane's layout, in which board window. /// /// The **registry** rather than the store: `heights` and `grids` belong to one board window, so /// two windows open on one board are two layouts even though they share a store and a lane id. private struct Key: Hashable { var registry: ObjectIdentifier var laneID: ItemID } /// A layout plus everything it was derived from — the whole of what invalidates it. private struct Entry { var boardRoot: BoardRootKey var generation: Int var heightsGeneration: Int var hidden: Set var layout: LaneRestingLayout } private var entries: [Key: Entry] = [:] /// How many layouts this drag has built, and how many samples were answered from one already /// standing — the observation handle that makes "the steady state is a containment scan" a test /// rather than a hope (`RestingLayoutCacheTests`). /// /// Instance counters rather than `BoardLoader.ParseCounter`'s injected object, because the /// subject is different: a walk is a stateless static called from several tasks at once and needs /// somewhere per-walk to tally into, while this is one object per drag whose whole lifetime is /// the question being asked. Nothing in the app reads them. private(set) var builds = 0 private(set) var reuses = 0 init() {} /// `laneID`'s resting layout on `store`'s current snapshot, built if this is the first sample to /// ask for it since anything it derives from moved. /// /// - Returns: `nil` when `laneID` is not in the snapshot at all — the vanished-lane case, which /// is rule 2's and the caller's to answer (`BoardDropContext.revalidateProposal`). func layout( inLane laneID: ItemID, of store: BoardStore, registry: LaneDropRegistry, hidden: Set ) -> LaneRestingLayout? { let key = Key(registry: ObjectIdentifier(registry), laneID: laneID) if let entry = entries[key], entry.generation == store.snapshotGeneration, entry.heightsGeneration == registry.heightsGeneration, entry.boardRoot == store.rootKey, entry.hidden == hidden { reuses += 1 return entry.layout } guard let lane = store.snapshot.lanes.first(where: { $0.id == laneID }) else { entries.removeValue(forKey: key) return nil } var cardIDs: [ItemID] = [] var heights: [CGFloat] = [] cardIDs.reserveCapacity(lane.cards.count) heights.reserveCapacity(lane.cards.count) for card in lane.cards where !hidden.contains(card.id) { cardIDs.append(card.id) heights.append(registry.heights[card.id] ?? LaneDropRegistry.nominalCardHeight) } let layout = LaneRestingLayout(cardIDs: cardIDs, heights: heights) entries[key] = Entry( boardRoot: store.rootKey, generation: store.snapshotGeneration, heightsGeneration: registry.heightsGeneration, hidden: hidden, layout: layout ) builds += 1 return layout } /// Drops every layout — the drag that filled them is over, or a new one is starting. func clear() { entries.removeAll(keepingCapacity: true) builds = 0 reuses = 0 } } // 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 /// Whether two of these would send a drop to the same place — **the whole of what this value /// contributes to `LaneView.==` and `CardFaceView.==`.** /// /// `BoardView.dropContext` rebuilds this struct on every body pass, closures and all, so a naive /// value comparison is impossible and a reference comparison is meaningless: the three closures /// are freshly allocated each time and would make every lane and every card face unequal on every /// pass, which is exactly the rebuild the gates exist to stop. They are also the members it is /// safe to ignore — each one *reads* window and strip geometry at event time rather than carrying /// any (see this type's own note), so two contexts with the same store, session and registry /// resolve every hover identically whatever closure objects they happen to hold. nonisolated func isEquivalent(to other: BoardDropContext) -> Bool { store === other.store && session === other.session && registry === other.registry && gap == other.gap } // 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 vanished in a reload is invalidated — deleted 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, proposal.boardRoot == store.rootKey, let laneID = proposal.laneID else { return } guard !store.snapshot.lanes.contains(where: { $0.id == laneID }) 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 column consumes one unit while /// shown and is never a *position* on the strip (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. The delete a lane drag can /// propose over the column is not a slot at all — it is the column's own target answering /// (`retargetTrash`), and it names `.trash` rather than an index in this list. /// /// **These zones never see a re-admitted lane, and that is an invariant rather than an /// accident.** `hiddenMembers` now leaves a copy's originals in the resting layout, but a lane /// can only be re-admitted into the strip it was picked up from, and a lane drag over its own /// board never resolves to `.copy` (`DragLocality.operation`'s first carve-out). A cross-board /// lane copy does re-admit — into the **source** strip, while the cursor is over the foreign /// board, where no zone of this board's is being consulted at all. So the strip's slot list is /// still "the lanes minus the run" every time this function actually runs, and `moveLanes` / /// `receiveLanes` keep the index space they always had. func retargetLanes() { guard session.isDraggingLanes, let cursor = stripCursor() else { return } let hidden = session.hiddenMembers(onBoardRooted: store.rootKey) let resting = store.snapshot.lanes.filter { !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.rootKey) ) 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.rootKey, container: .strip, 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). /// /// **The zones are built over whatever `hiddenMembers` leaves standing**, which is what makes /// the proposal's index space follow the operation for free: a within-board move counts slots in /// the lane minus the dragged run, a within-board ⌥-copy counts them in the lane *including* the /// originals, because that is the layout the user is looking at. `DropSlotMath` has no opinion /// on the matter — the caller decides what the resting layout contains — and the commit's two /// writers are counted in exactly these two spaces (`BoardStore.moveCards` / `copyCards`). /// /// **The layout is rebuilt per snapshot, the zones are resolved per sample** (`RestingLayoutCache` /// — including why that keeps rule 1 of the re-grounding trio exactly). What is left here at /// event time is the geometry that genuinely moves under a stationary cursor: the grid, which the /// autoscroll driver scrolls on purpose, and `DropSlotMath`'s own containment arithmetic. func retargetCards(inLane laneID: ItemID) { guard session.isDraggingCards, let cursor = globalCursor() else { return } guard let resting = session.restingLayouts.layout( inLane: laneID, of: store, registry: registry, hidden: session.hiddenMembers(onBoardRooted: store.rootKey) ) else { revalidateProposal() return } guard let grid = registry.grids[laneID] else { return } let slot = DropSlotMath.cardSlot( cursor: cursor, placement: grid.placement, heights: resting.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.rootKey, laneID: laneID) ) guard let slot else { return } // a dead region: hold session.propose(DropTarget(boardRoot: store.rootKey, container: .lane(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 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: Retargeting — the trash column /// Whether the trash column would take the session in flight right now — `TrashDrop.accepts` /// with this board's state read in (04-interactions.md ▸ The trash, settled 2026-07-28). /// /// A method rather than a property because it is not free of consequence: the operation is /// re-resolved against the modifiers *at this instant*, which is also what keeps the badge honest /// while the cursor sits over the column (`dropProposal`). func acceptsTrashDrop() -> Bool { guard let sourceRoot = session.sourceRoot else { return false } return TrashDrop.accepts( kind: session.kind, container: session.container, isWithinBoard: sourceRoot == store.rootKey, operation: session.resolveOperation(destinationRoot: store.rootKey), isTrashShown: store.transient.isTrashVisible, acceptsMutations: store.acceptsBoardMutations ) } /// Where a session over the **trash column** would land: the topmost row, or nowhere. /// /// **A refusal falls through to the strip's own answer rather than withdrawing the proposal**, /// which is precisely what this column did before it had a drop target of its own: a cursor over /// it resolves to no lane, so `retargetCardsFromStrip` holds whatever the shadows already show /// and `retargetLanes` clamps the terminal slot in front of the trash column. That is the /// hysteresis contract (DRAG-REORDER.md § Hysteresis) and it is also the honest reading of "the /// trash proposes nothing for you": the column declines to be a target, it does not cancel the /// drag the user is still holding. So a lane drag reorders across the column exactly as it always /// did, and an ⌥-copy released over it still lands where its shadows are. func retargetTrash() { revalidateProposal() guard acceptsTrashDrop() else { retargetFromStrip() return } session.propose(DropTarget( boardRoot: store.rootKey, container: .trash, index: TrashDrop.landingIndex )) } // MARK: Retargeting — external Finder file sessions /// Whether `info` is an **external Finder file** session rather than one of ours. /// /// Never ambiguous: our own drags arm `DragSession` synchronously at `.onDrag` time, before any /// drop callback can arrive, so a session that is not active but carries `.fileURL` items came /// from outside the app. (A board drag also carries a plain-text representation and no file URL, /// so the two type sets never overlap.) func isFileSession(_ info: DropInfo) -> Bool { !session.isActive && info.hasItemsConforming(to: [.fileURL]) } /// Whether this board accepts a file drop at all — **the mutating-gesture rule, applied to the /// one gesture that arrives from outside the app**: under the read-only lock (02-architecture.md /// § The lock's scope) or with an inline title editor focused (04-interactions.md ▸ Grammar's /// focused-editor rule) a file drop refuses at the board, with no highlight and no proposal — /// the same refusal `.onDrag` makes by handing back an empty item provider. var acceptsFileDrops: Bool { !store.isReadOnly && !store.isEditingInline } /// Whether this board accepts *this* drag — the board's own state **and the payload's** /// (04-interactions.md ▸ Drag and drop: "Folders are refused at hover"). /// /// A drag carrying nothing but folders is refused here, at every delegate's `validateDrop` and /// again before any retarget, which is the whole of "a drag containing only folders never /// engages — no highlight, no drop proposal, the standard incompatible-payload read". A mixed /// drag engages for its files alone: it has something to import, and the folders are named at /// the drop (`FinderDrop.land`). func acceptsFileDrop(_ info: DropInfo) -> Bool { acceptsFileDrops && FinderDrop.importableCount(info.itemProviders(for: [.fileURL])) > 0 } /// Where a **file** session would land in `laneID` — the file mode's twin of `retargetCards`, /// resolved against the very same analytic masonry geometry. /// /// The three answers and the order they are asked in are `FileDropZones.landing`'s, kept there so /// the ruling is checkable without a window; this is the adapter that feeds it the snapshot and /// the registry and turns its answer into a proposal. In short: the **header** is the topmost /// position, a **card under the cursor** attaches, and everything else is the **create slot** the /// ordinary card zones produce. /// /// **Created cards land at the drop position** (04-interactions.md ▸ Drag and drop, settled /// 2026-07-28): "resolved through the same card-grid zones an ordinary card drag uses, shadow /// included — drops are positional everywhere, and append-at-bottom stays the creation *trio*'s /// rule, not the drop's." /// /// **The landing shadow is the create path's whole feedback** (settled, same bullet): no lane-level /// highlight is proposed here or drawn anywhere, because "each target gets one clear signal, and /// the card-attach highlight exists precisely because that target has no shadow". /// /// While a create shadow is open the *drawn* cards sit lower than their resting frames, which is /// exactly the tradeoff every proposal in this app makes: the answer stays a pure function of the /// cursor and the snapshot, so it cannot oscillate — the drawn layout never feeds back into it. func retargetFile(inLane laneID: ItemID, info: DropInfo) { guard acceptsFileDrop(info), let cursor = globalCursor(), let lane = store.snapshot.lanes.first(where: { $0.id == laneID }), let grid = registry.grids[laneID] else { session.proposeFile(nil) return } let rendered = lane.cards let heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight } let count = FinderDrop.shadowCount(info.itemProviders(for: [.fileURL])) let landing = FileDropZones.landing( cursor: cursor, headerBottom: registry.headers[laneID]?.maxY, placement: grid.placement, heights: heights, nominalHeight: LaneDropRegistry.nominalCardHeight, current: session.fileLaneProposal(onBoardRooted: store.rootKey, laneID: laneID)?.index ) switch landing { case .hold: return // a dead region: hold whatever the create slot already was case let .attach(index): guard rendered.indices.contains(index) else { return } session.proposeFile(FileDropTarget( boardRoot: store.rootKey, landing: .attach(cardID: rendered[index].id), fileCount: count )) case let .create(index): session.proposeFile(FileDropTarget( boardRoot: store.rootKey, landing: .create(laneID: laneID, index: index), fileCount: count )) } } /// The strip's fall-through for file sessions: which lane is under the cursor, analytically. /// /// Both a 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 surfaces — and unlike a card session, those /// surfaces genuinely clear the proposal rather than holding it. A cursor over a gap, the outer /// margin, or **the trash column** is over no lane at all (`LaneLayoutMath.laneIndex` answers /// `nil` there, since the trash column is absent from the lane list by construction), so the /// highlight withdraws and a release refuses: "Finder file drops (attachment import) on /// trash cards are inert" and the trash column is never a file-drop target (04-interactions.md ▸ The /// trash). func retargetFileFromStrip(_ info: DropInfo) { guard acceptsFileDrop(info), let cursor = stripCursor() else { session.proposeFile(nil) return } let lanes = store.snapshot.lanes 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 { session.proposeFile(nil) return } retargetFile(inLane: lanes[index].id, info: info) } /// What `dropUpdated` answers for a file session: always `.copy` — importing a file leaves the /// original where it was, which is what the badge should say — and `.cancel` when nothing under /// the cursor will take it. func fileDropProposal() -> DropProposal { session.fileTarget != nil ? DropProposal(operation: .copy) : DropProposal(operation: .cancel) } /// Commits a file drop: the files land where the highlight or the shadows showed. /// /// **The target is captured and the hover state cleared before the load starts.** A provider's /// file URL loads asynchronously — it is never synchronous for a Finder drag — and a fast second /// drag arriving in the meantime must not find a stale target sitting there. The store call then /// happens back on the main actor, one `performWrite` bracket per gesture, whatever the file /// count (`BoardStore.importAttachments` / `createCards`). /// /// **The resolved URLs are the authority on what is a folder** (04-interactions.md ▸ Drag and /// drop): the hover read is a declared-type guess and the drop is a filesystem fact, so /// `FinderDrop.land` re-partitions here and writes only the files — see its own note on why the /// two reads cannot be one. func commitFileDrop(_ info: DropInfo) -> Bool { guard acceptsFileDrops, let target = session.fileTarget, target.boardRoot == store.rootKey else { session.proposeFile(nil) return false } let providers = info.itemProviders(for: [.fileURL]) session.proposeFile(nil) guard !providers.isEmpty else { return false } let store = self.store Task { @MainActor in var urls: [URL] = [] for provider in providers { if let url = await FileDropLoading.url(from: provider) { urls.append(url) } } guard !urls.isEmpty else { return } // The sandbox's half: a Finder drag hands the app an extension for what it dropped, and // the copy is the read that needs it. `start…` answers false for a URL that carries no // scope of its own — an ordinary in-container path — so only the ones that opened are // closed again. let scoped = urls.filter { $0.startAccessingSecurityScopedResource() } defer { for url in scoped { url.stopAccessingSecurityScopedResource() } } // Inside the scope: the directory read is itself a read of the dropped item, and the // extension the drag handed over is what makes it answer honestly. FinderDrop.land(urls, landing: target.landing, into: store) } return true } // 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, proposal.boardRoot == store.rootKey else { return DropProposal(operation: .cancel) } let operation = session.resolveOperation(destinationRoot: store.rootKey) 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 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). /// /// **One of the containers is not a destination but a verb.** A proposal naming the trash commits /// a delete — the same write ⌫ performs, through the same `BoardWriter.deleteCardToTrash` / /// `deleteLaneToTrash` in the same bracket (`BoardStore.deleteByDrag`, `deleteLanesByDrag`), so an /// item deleted by drop is indistinguishable on disk from one deleted by keystroke /// (04-interactions.md ▸ The trash, settled 2026-07-28; lanes extended 2026-07-29). /// /// The write is the first half; the second is the **committed-overlay hold** /// (`DragSession.commit`). The write is still in flight when this returns, so the session flips /// from proposing to committed and keeps drawing the arrangement it was showing — the shadows at /// their landing slots, the originals lifted out — until this store's echo reload lands. func commitDrop() -> Bool { guard session.isActive, let kind = session.kind, let sourceRoot = session.sourceRoot else { return false } revalidateProposal() guard let target = session.proposal, target.boardRoot == store.rootKey else { cancelDrop() return false } let survivors = session.survivors guard !survivors.isEmpty else { cancelDrop() return false } // **A mixed-kind drag never leaves the trash** (04-interactions.md ▸ The trash, ruled // 2026-07-31 with kind-blind trash selection): "pickup is allowed — the selection is legal — // but every out-of-trash drop target refuses the mixed payload, and the release surfaces a // notice explaining the rule … the refused drag ends like any refusal, rows staying put". // // One guard for every out-of-trash target, because every one of them funnels through this // commit — the lane masonries, the strip, the cross-board arrivals, and the window fallback // alike. A *within*-trash release cannot reach it: the column declines `.trash`-container // sessions outright (`TrashDrop.accepts`), so a trash-origin drag never holds a `.trash` // proposal, and "within-trash drops stay inert" needs no clause here. // // The notice is the destination board's strip, which is where the user is looking. guard !(session.container == .trash && session.mixesKinds) else { store.banners.postMixedTrashDrag() cancelDrop() return false } let ids = survivors.map { session.members[$0] } let folders = survivors.map { session.folders[$0] } let within = sourceRoot == store.rootKey let operation = session.resolveOperation(destinationRoot: store.rootKey) switch kind { case .lanes: if target.isTrash { // **The pointer's delete gesture, at the lane level** (04-interactions.md ▸ The // trash, lanes extended 2026-07-29: "a lane drag over the shown trash proposes the // delete alongside its strip slots"): release moves the lane's folder — subtree // intact — into `.trash/`, exactly the ⌫ delete (`BoardStore.deleteLanesByDrag`). // // The gate is re-asked here rather than trusted from the hover, the card branch's // rule for its reason: the modifiers can change after the proposal stood, and no // callback reports it. A refusal cancels — the lane returns, nothing is written. guard TrashDrop.accepts( kind: kind, container: session.container, isWithinBoard: within, operation: operation, isTrashShown: store.transient.isTrashVisible, acceptsMutations: store.acceptsBoardMutations ) else { cancelDrop() return false } store.deleteLanesByDrag(laneIDs: ids) break } // **A lane drag never targets a masonry** — a lane session proposes lane slots and the // trash column, and nothing else (04-interactions.md ▸ The trash). True by construction, // since `retargetLanes` and `retargetTrash` are the only things that propose for one; // written down because a commit that trusted the container implicitly would be the one // place the invariant could break silently. guard target.container == .strip else { cancelDrop() return false } if within { // **The trash side is the restore, and it is a different write** (04 ▸ The trash: // "a trashed lane row [dropped] onto its own board's strip — is an ordinary move to // the drop position"): the folder comes *out* of `.trash/`, where `moveLanes`' // rank arithmetic — a permutation of the strip's own lanes — has nothing to say // about it. `restoreLanes` is that move, with the same one-bracket, one-step shape. if session.container == .trash { store.restoreLanes(Set(ids), toIndex: target.index) } else { store.moveLanes(Set(ids), toIndex: target.index) } } else { // Cross-board, from either container: the ordinary arrival, copy by default and // move under ⌘ — "Dropped on *another* board it follows the copy default … ⌘-drag // forces the true cross-board restore-move" (04 ▸ The trash). store.receiveLanes(folders, operation: operation, at: target.index) } case .cards: if target.isTrash { // **The pointer's delete gesture** (04-interactions.md ▸ The trash, settled // 2026-07-28): "release moves the dragged card(s) into `.trash/`" — exactly the ⌫ // delete. // // The gate is re-asked here rather than trusted from the hover, because the one input // that can change between them arrives through no callback at all: ⌥ pressed after // the proposal stood would otherwise delete an original the copy grammar had just // promised to leave alone. A refusal cancels — items return, nothing is written. guard TrashDrop.accepts( kind: kind, container: session.container, isWithinBoard: within, operation: operation, isTrashShown: store.transient.isTrashVisible, acceptsMutations: store.acceptsBoardMutations ) else { cancelDrop() return false } store.deleteByDrag(cardIDs: ids) break } guard let laneID = target.laneID else { cancelDrop() return false } // **The trash side needs no branch of its own any more** (04-interactions.md ▸ The // trash, resettled 2026-07-28: "Drag-to-restore follows the locality model: dropping a // trash card into one of its own board's lanes is an ordinary move to the drop // position"). `moveCards`/`copyCards` resolve their members in either container, so a // restore *is* the within-board move and a cross-board restore *is* the ordinary // arrival — which is exactly what retiring the restore-specific machinery bought. if within { // **Each writer is counted in the layout its own gesture was showing.** The zones // that produced `target.index` were built over the resting layout for *this* // operation (`retargetCards` → `DragSession.hiddenMembers`): a move's excludes the // dragged run, a copy's includes the originals, because a copy leaves them there. // So the number goes to each writer unrewritten and the drop lands where the shadow // was — the two index spaces differ, and neither writer has to guess which it got. if operation == .copy { store.copyCards(Set(ids), toLane: laneID, at: target.index) } else { store.moveCards(Set(ids), toLane: laneID, at: target.index) } } else { store.receiveCards(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() } } } // The Finder-drag payload rules this file used to hold — `FileDropLoading` and `FinderDrop` — moved // to `Kanban/UI/FinderDrop.swift` verbatim when the card window's whole-window attachment drop // (05-card-window.md ▸ Attachments) became their second caller. Everything below still calls them by // the same names; only their file changed. // 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 **all three** session types — cards, lanes, and external Finder file drags // — and routes internally; a region whose topmost target understood only some of them would be a // dead zone for the rest — no hover callbacks, and a release there would snap back instead of // committing. // // **The file routing is resolved inside these delegates, not by a drop target of the card's own.** // A per-face `onDrop` would be the deepest region under the cursor and would therefore have to // re-implement card and lane routing too, just to avoid becoming a dead zone for them — a second // copy of the dispatch, able to disagree with this one. Instead a card under the cursor is found by // hit-testing the *analytic* masonry frames the card zones are already built from // (`BoardDropContext.retargetFile`), which adds no drop region at all and cannot drift from the // geometry the shadows use. // // **A file session is validated by its payload, not only by its type** (04-interactions.md ▸ Drag // and drop: "Folders are refused at hover"). Every `validateDrop` below asks // `BoardDropContext.acceptsFileDrop`, which answers false for a drag carrying nothing but folders — // so the system reads the board as an incompatible target for it: no `dropEntered`, no highlight, no // shadows, and a refusal cursor at the drop. A *mixed* drag validates, proposes for its files alone, // and names the folders it left behind when it lands (`FinderDrop`). /// The board's own drag types. Spelled once so no target can accidentally accept fewer. let boardDragTypes: [UTType] = [.laneworkCards, .laneworkLanes] /// Every type a board surface's `onDrop` declares: our own two, plus the external Finder file drag /// (04-interactions.md ▸ Drag and drop). **A target that left `.fileURL` off would be a dead zone /// that strands a file session** — no hover callbacks, and a refused release with nothing to explain /// why (DRAG-REORDER.md § Single-target dispatch). let boardDropTypes: [UTType] = boardDragTypes + [.fileURL] /// 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. **File sessions** resolve against those same /// masonry zones — onto a card they become attachments, onto the grid one card per file at the drop /// position, and onto the **header** the topmost position, since the target is the whole lane body /// and a dead stripe across its top would be the one place a file drop refused for no reason /// (04-interactions.md ▸ Drag and drop, settled 2026-07-28). struct LaneDropDelegate: DropDelegate { let context: BoardDropContext let laneID: ItemID func validateDrop(info: DropInfo) -> Bool { if context.isFileSession(info) { return context.acceptsFileDrop(info) } return context.session.isActive && info.hasItemsConforming(to: boardDragTypes) } func dropEntered(info: DropInfo) { retarget(info) } func dropUpdated(info: DropInfo) -> DropProposal? { retarget(info) return context.isFileSession(info) ? context.fileDropProposal() : context.dropProposal() } /// **Only file sessions have an exit hook.** A card or lane proposal is meant to *hold* while the /// cursor leaves for ambiguous territory — that is the hysteresis contract (DRAG-REORDER.md § /// Hysteresis) — while a file proposal is only ever "what is under the cursor now", so leaving /// the lane body clears the highlight. func dropExited(info: DropInfo) { guard context.isFileSession(info) else { return } context.session.proposeFile(nil) } func performDrop(info: DropInfo) -> Bool { context.isFileSession(info) ? context.commitFileDrop(info) : context.commitDrop() } private func retarget(_ info: DropInfo) { if context.isFileSession(info) { context.retargetFile(inLane: laneID, info: info) return } context.revalidateProposal() if context.session.isDraggingLanes { context.retargetLanes() } else { context.retargetCards(inLane: laneID) } } } /// The strip's drop target — the backdrop, the gaps and the outer margin. /// /// The trash column used to fall through to here too, being no landing spot of its own; it has its /// own target now that a live card drag can *delete* into it (`TrashDropDelegate`), and that target /// hands every session it declines straight back to this logic, so the behaviour over the column is /// unchanged for everything but the one gesture that is new. /// /// 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. /// File sessions are the strip's twin safety net, and here its own surfaces are a *live* handler for /// them rather than only a fallback: a file drag has no proposal to hold, so hovering a gap, the /// outer margin, or the trash column clears the target outright. struct StripDropDelegate: DropDelegate { let context: BoardDropContext func validateDrop(info: DropInfo) -> Bool { if context.isFileSession(info) { return context.acceptsFileDrop(info) } return context.session.isActive && info.hasItemsConforming(to: boardDragTypes) } func dropEntered(info: DropInfo) { retarget(info) } func dropUpdated(info: DropInfo) -> DropProposal? { retarget(info) return context.isFileSession(info) ? context.fileDropProposal() : context.dropProposal() } /// See `LaneDropDelegate.dropExited` — the file mode's clear, and nothing for our own sessions. func dropExited(info: DropInfo) { guard context.isFileSession(info) else { return } context.session.proposeFile(nil) } func performDrop(info: DropInfo) -> Bool { context.isFileSession(info) ? context.commitFileDrop(info) : context.commitDrop() } private func retarget(_ info: DropInfo) { if context.isFileSession(info) { context.retargetFileFromStrip(info) } else { context.retargetFromStrip() } } } /// The **trash column's** drop target — the pointer's delete gesture (04-interactions.md ▸ The /// trash, settled 2026-07-28: "dropping a live card on the shown trash deletes it"). /// /// It exists only while the column does. Hidden, the trash renders nothing, so there is no region /// here to enter and "the trash stays undroppable-into while hidden, like every gesture" needs no /// code — `TrashDrop.accepts` restates it anyway, because an invariant that is only true by /// construction is worth being able to point at. /// /// Like every other delegate it accepts **all three** session types, because single-target dispatch /// gives the deepest region the session whether it wants it or not (see the note above), and it /// routes them three ways: /// /// - **card and lane sessions alike** through `retargetTrash`, which proposes the topmost row for the /// ones the trash takes — a live item from this board, unmodified, either kind (lanes extended /// 2026-07-29) — and falls through to the strip's own answer for the rest, so a *trashed* row being /// dragged out keeps reordering against the strip across the column exactly as it did when the /// column was a hole in the strip's target; /// - **Finder file sessions** by clearing the highlight outright: "Finder file drops (attachment /// import) on trash cards are inert" (▸ The trash), and the column has nothing else to offer /// them — no lane, no card, nothing to attach to. struct TrashDropDelegate: DropDelegate { let context: BoardDropContext func validateDrop(info: DropInfo) -> Bool { if context.isFileSession(info) { return context.acceptsFileDrop(info) } return context.session.isActive && info.hasItemsConforming(to: boardDragTypes) } func dropEntered(info: DropInfo) { retarget(info) } func dropUpdated(info: DropInfo) -> DropProposal? { retarget(info) return context.isFileSession(info) ? context.fileDropProposal() : context.dropProposal() } /// See `LaneDropDelegate.dropExited` — the file mode's clear, and nothing for our own sessions, /// whose proposals are meant to hold across ambiguous territory. func dropExited(info: DropInfo) { guard context.isFileSession(info) else { return } context.session.proposeFile(nil) } /// A release on the column commits whatever stands — the delete when the trash is the /// proposal, and otherwise the proposal the column declined to displace, which is the same /// "the drop lands where the shadows show" promise as anywhere else. func performDrop(info: DropInfo) -> Bool { context.isFileSession(info) ? context.commitFileDrop(info) : context.commitDrop() } private func retarget(_ info: DropInfo) { if context.isFileSession(info) { context.session.proposeFile(nil) return } context.retargetTrash() } } /// 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. /// A file session released over uncovered window chrome commits whatever the file target currently /// names, exactly as this commits the current card/lane proposal — and with no target standing it /// simply refuses, which is the honest answer for a release over nothing. struct BoardFallbackDropDelegate: DropDelegate { let context: BoardDropContext func validateDrop(info: DropInfo) -> Bool { if context.isFileSession(info) { return context.acceptsFileDrop(info) } return context.session.isActive && info.hasItemsConforming(to: boardDragTypes) } func dropUpdated(info: DropInfo) -> DropProposal? { context.isFileSession(info) ? context.fileDropProposal() : context.dropProposal() } /// The drag left the window's last covering region — with nothing below this one to take over, /// a standing file highlight would be stale. func dropExited(info: DropInfo) { guard context.isFileSession(info) else { return } context.session.proposeFile(nil) } func performDrop(info: DropInfo) -> Bool { context.isFileSession(info) ? context.commitFileDrop(info) : context.commitDrop() } }