View ▸ Zoom In / Zoom Out / Actual Size (⌘+ / ⌘− / ⌘0): 75%–200% in eight rungs, app-wide and persisted (the Show Comments precedent) — a viewing comfort, not a property of any one board. The level travels as BoardZoomContext in the environment, injected on BoardView alone so the banner strip, search bar, sheets and popovers stay at the system size; the environment is also what carries it through CardFaceView's equality gate, which compares nothing that moves with the level. Every BoardMetrics figure follows zoom.bodyPointSize — card and lane chrome, drag replicas and the count badge, the resize handle, the trash column — and the drop registry carries the ruler for event-time reads, with the autoscroller's three reaches turning font-derived (reachSide named as the stripGap it always equalled). Lanes still divide the window; zoom never moves the window or its floor. The toolbar gains a catalog-only Zoom In/Out pair mirroring the menu rows' predicate; zoom holds shut mid-drag (frozen geometry), each rung announces itself to VoiceOver, and the render suite pins both invariants: a rung repaints every face, a no-op Actual Size repaints nothing. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
1235 lines
65 KiB
Swift
1235 lines
65 KiB
Swift
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 board's current ruler, written by `BoardView` from the zoom environment (03-board-ui.md
|
||
/// ▸ Layout — zoom).
|
||
///
|
||
/// It lives here for `stripFrame`'s reason: the drop delegates run at event time, outside any
|
||
/// body evaluation, so a number they need has to be *on* the registry rather than captured from a
|
||
/// view's last render. The default is the system size, which is what an un-zoomed board and every
|
||
/// test that never sets it get.
|
||
var bodyPointSize: CGFloat = BoardMetrics.bodyPointSize
|
||
|
||
/// 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.
|
||
///
|
||
/// An instance property rather than a static for exactly that reason extended to zoom: the guess
|
||
/// has to be made on the ruler the board is *currently* drawing with, and the level is a property
|
||
/// of the window this registry belongs to.
|
||
var nominalCardHeight: CGFloat {
|
||
BoardMetrics.nominalCardHeight(bodyPointSize: 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<ItemID>
|
||
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<ItemID>
|
||
) -> 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] ?? registry.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 else { return }
|
||
// Before the cursor, so the flip's replay address is "the surface that got the event" rather
|
||
// than "the surface that managed to answer" (`DragSession.noteRetarget`).
|
||
session.noteRetarget(.strip, registry: registry)
|
||
guard 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) {
|
||
// `DragSignposts` — begin/end with a `defer` rather than a scoped closure, so the guards
|
||
// below keep their early returns and this reads as instrumentation rather than a rewrite.
|
||
#if DEBUG
|
||
let span = DragSignposts.beginRetargetCards()
|
||
defer { DragSignposts.endRetargetCards(span) }
|
||
#endif
|
||
guard session.isDraggingCards else { return }
|
||
// See `retargetLanes` — recorded before the cursor is read, and by the retarget rather than
|
||
// by its callers, so the delegate, the strip's fall-through and the autoscroll driver all
|
||
// leave the same address behind.
|
||
session.noteRetarget(.lane(laneID), registry: registry)
|
||
guard 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 ?? registry.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`).
|
||
///
|
||
/// `modifiers` defaults to the live flags, which is the only thing the app ever passes — the
|
||
/// parameter is `DragSession.resolveOperation`'s own seam, one rung out, so the refusal this
|
||
/// gate exists for (⌥ over the column) is checkable without a keyboard (`ModifierFlipTests`).
|
||
func acceptsTrashDrop(modifiers: NSEvent.ModifierFlags = NSEvent.modifierFlags) -> 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, modifiers: modifiers),
|
||
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(modifiers: NSEvent.ModifierFlags = NSEvent.modifierFlags) {
|
||
// A session, or there is nothing to record an address for. Provably a no-op otherwise —
|
||
// every branch below is kind-guarded one level down — and stated so that "an origin means a
|
||
// drag" holds (`DragSession.noteRetarget`).
|
||
guard session.isActive else { return }
|
||
revalidateProposal()
|
||
// The fall-through below may overwrite this address with the surface that actually answered,
|
||
// and that is exactly right: a flip replays whatever resolved the proposal last. The one
|
||
// case this gate exists for keeps it — a cursor over the column resolves to no lane, so the
|
||
// strip's card fall-through holds without recording, and the column stays the address the
|
||
// ⌥-release has to be re-asked at.
|
||
session.noteRetarget(.trash, registry: registry)
|
||
guard acceptsTrashDrop(modifiers: modifiers) else {
|
||
retargetFromStrip()
|
||
return
|
||
}
|
||
session.propose(DropTarget(
|
||
boardRoot: store.rootKey,
|
||
container: .trash,
|
||
index: TrashDrop.landingIndex
|
||
))
|
||
}
|
||
|
||
// MARK: Retargeting — the modifier flip
|
||
|
||
/// **The stationary modifier flip's answer** (04-interactions.md ▸ Drag and drop: "⌥ always
|
||
/// forces copy and ⌘ always forces move … the badge tracks the effective operation live").
|
||
///
|
||
/// The modifiers are sampled inside `resolveOperation`, which is reached from `dropUpdated` and
|
||
/// the commit — and drop callbacks arrive only while the mouse **moves**. So ⌥ pressed with the
|
||
/// pointer perfectly still used to change nothing visible until the next twitch: the source
|
||
/// board's originals stayed lifted, the trash kept refusing (or kept offering) against the old
|
||
/// operation, and the shadows stood in an index space the release would no longer count in.
|
||
/// This is the missing sample, arriving from the keyboard instead of from the mouse.
|
||
///
|
||
/// **The third client of the one shared retarget** (DRAG-REORDER.md § Edge autoscroll: "the
|
||
/// lane's drop delegate and the autoscroll driver must go through one shared retarget, so they
|
||
/// can never disagree"). It resolves nothing itself — it re-runs the retarget that resolved the
|
||
/// standing proposal, at the surface that resolved it (`DragSession.RetargetOrigin`), so a flip
|
||
/// and a mouse sample land in exactly the same place by construction.
|
||
///
|
||
/// **No location comes off the flip event, and none is needed.** Every retarget reads the
|
||
/// physical cursor through this window (`globalCursor`), which is precisely why the autoscroll
|
||
/// driver can re-propose against a stationary pointer while the content moves under it; here the
|
||
/// content stands still and the *operation* moves instead.
|
||
///
|
||
/// Three guards, in the order that makes each one cheap:
|
||
///
|
||
/// - **A settled release is past retargeting.** `resolveOperation` and `propose` both freeze
|
||
/// under a hold, so this would be inert anyway; refusing outright means a flip during the
|
||
/// settle costs nothing at all, and states the freeze where it can be read.
|
||
/// - **Only the window under the cursor answers.** The origin names a `LaneDropRegistry`, which
|
||
/// is per board *window* — so a cross-board drag re-proposes against the board being hovered
|
||
/// rather than the one it was picked up from, two windows on one board do not both answer, and
|
||
/// a window closed mid-drag drops out silently (the reference is weak).
|
||
/// - **The operation is re-resolved before the retarget, not after it.** `dropUpdated` runs them
|
||
/// the other way round and can afford to: at 60+ samples a second the next event repairs the
|
||
/// one-sample lag. A flip gets one shot, and the retarget's index space is a function of the
|
||
/// operation (`retargetCards` → `hiddenMembers`), so the operation has to be current *first*
|
||
/// or the shadows would land in the space the flip just left.
|
||
func retargetAfterModifierFlip(modifiers: NSEvent.ModifierFlags = NSEvent.modifierFlags) {
|
||
guard session.isActive, session.hold == nil else { return }
|
||
guard let origin = session.retargetOrigin, origin.registry === registry else { return }
|
||
|
||
session.resolveOperation(destinationRoot: store.rootKey, modifiers: modifiers)
|
||
|
||
switch origin.surface {
|
||
case .strip:
|
||
retargetFromStrip()
|
||
case let .lane(laneID):
|
||
// The lane delegate's own pair, in its order: rule 2 first, then the masonry's zones.
|
||
revalidateProposal()
|
||
retargetCards(inLane: laneID)
|
||
case .trash:
|
||
retargetTrash(modifiers: modifiers)
|
||
}
|
||
}
|
||
|
||
// 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] ?? registry.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: registry.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 {
|
||
// The synchronous half of the release, every refusing branch included (`DragSignposts`); the
|
||
// *pause* the user feels starts inside, at `DragSession.commit(into:)`, and outlives this.
|
||
#if DEBUG
|
||
let span = DragSignposts.beginCommitDrop()
|
||
defer { DragSignposts.endCommitDrop(span) }
|
||
#endif
|
||
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? {
|
||
// The per-event span, and the input sample (`DragSignposts`). One delegate sees any one
|
||
// event — single-target dispatch has no fall-through — so the cadence is not double-counted.
|
||
#if DEBUG
|
||
let span = DragSignposts.beginDropUpdated()
|
||
DragSignposts.sampleInput()
|
||
defer { DragSignposts.endDropUpdated(span) }
|
||
#endif
|
||
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? {
|
||
#if DEBUG
|
||
let span = DragSignposts.beginDropUpdated()
|
||
DragSignposts.sampleInput()
|
||
defer { DragSignposts.endDropUpdated(span) }
|
||
#endif
|
||
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? {
|
||
#if DEBUG
|
||
let span = DragSignposts.beginDropUpdated()
|
||
DragSignposts.sampleInput()
|
||
defer { DragSignposts.endDropUpdated(span) }
|
||
#endif
|
||
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()
|
||
}
|
||
}
|