Land Finder file drops positionally, header release topmost

04's settled clauses were mostly shipped already — the create landing
resolved through DropSlotMath.cardSlot with one nominal shadow per
importable file — but a release on the lane header fell through to the
card zones, which clamp inward, so a scrolled lane could propose behind
the header stripe. FileDropZones now folds header, attach hit-test, and
card-slot resolution into one pure seam asked in that order, the header
answering topmost per the ruling; lane headers register their frames
for it. FinderDrop.shadowCount names the floor-at-one rule. New tests
pin the header boundary, a differential against cardSlot's own zones
(same zones, not similar), and a store-level differential proving a
file landing takes the very ranks a card move there takes.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 08:28:28 -04:00
parent 7be9bb2345
commit 524488122f
7 changed files with 392 additions and 72 deletions
+72 -55
View File
@@ -4,8 +4,8 @@ import UniformTypeIdentifiers
// MARK: - What each lane draws, as the drag reads it
/// Where each lane's card grid is drawn and how tall its cards are the measured half of the card
/// masonry's drop geometry, one registry per board window.
/// 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
@@ -25,6 +25,10 @@ import UniformTypeIdentifiers
/// 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.
@@ -54,9 +58,17 @@ final class LaneDropRegistry {
private(set) var grids: [ItemID: Grid] = [:]
private(set) var heights: [ItemID: CGFloat] = [:]
/// 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) { heights[cardID] = height }
func removeHeight(_ cardID: ItemID) { heights.removeValue(forKey: cardID) }
}
@@ -303,40 +315,27 @@ struct BoardDropContext {
acceptsFileDrops && FinderDrop.importableCount(info.itemProviders(for: [.fileURL])) > 0
}
/// How many **files** the session carries the shadow run's length on the create path, and the
/// count the create path is sized by: folders are not imported, so they draw no shadow and mint
/// no card (the refusal rule above).
///
/// Read from `info` on every sample rather than captured at `dropEntered`: a delegate can be
/// entered without this window ever having seen the enter callback (single-target dispatch hands
/// the session to whichever region is deepest), and a count that was never set would draw the
/// wrong number of shadows.
private func fileCount(_ info: DropInfo) -> Int {
max(1, FinderDrop.importableCount(info.itemProviders(for: [.fileURL])))
}
/// Where a **file** session would land in `laneID` the file mode's twin of `retargetCards`,
/// resolved against the very same analytic masonry geometry.
///
/// Two answers, in this order:
/// 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.
///
/// 1. **A card under the cursor always wins** (04-interactions.md Drag and drop): attach beats
/// create, anywhere on the card's bounds. The bounds are the resting frames
/// `MasonryPlacement.frames(heights:)` replays the same reconstruction the card-slot zones
/// are built from, never a measured frame (03-board-ui.md § Motion). 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.
/// 2. **Otherwise the create slot**, from `DropSlotMath.cardSlot` "new cards land at the drop
/// position using the same card-grid zone math ordinary card drags use". The footprint the
/// span cap is measured against is the nominal card height, since the cards being proposed do
/// not exist yet to have one; the cap only ever truncates a zone that lies *over* an existing
/// card, and that region is case 1's.
/// **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."
///
/// **Positional landing is filed for design ratification.** 04's bullet says only "dropped on
/// lane empty space creates a card"; that the card lands at the *drop position* rather than at
/// the lane's bottom is this milestone's reading of the pathfinder's `retargetFile` precedent,
/// implemented here and awaiting the design's word.
/// **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 && !$0.isDeleted }),
@@ -355,36 +354,34 @@ struct BoardDropContext {
spacing: grid.spacing,
origin: grid.frame.origin
)
let count = fileCount(info)
let count = FinderDrop.shadowCount(info.itemProviders(for: [.fileURL]))
// Closed containment a cursor sitting exactly on a shared edge still counts, and the first
// match wins, so the answer is deterministic however the frames abut.
let frames = placement.frames(heights: heights)
if let index = frames.firstIndex(where: { frame in
cursor.x >= frame.minX && cursor.x <= frame.maxX
&& cursor.y >= frame.minY && cursor.y <= frame.maxY
}), index < rendered.count {
let landing = FileDropZones.landing(
cursor: cursor,
headerBottom: registry.headers[laneID]?.maxY,
placement: placement,
heights: heights,
nominalHeight: LaneDropRegistry.nominalCardHeight,
current: session.fileLaneProposal(onBoardRooted: store.rootURL, 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.rootURL,
landing: .attach(cardID: rendered[index].id),
fileCount: count
))
return
case let .create(index):
session.proposeFile(FileDropTarget(
boardRoot: store.rootURL,
landing: .create(laneID: laneID, index: index),
fileCount: count
))
}
let slot = DropSlotMath.cardSlot(
cursor: cursor,
placement: placement,
heights: heights,
draggedHeight: LaneDropRegistry.nominalCardHeight,
current: session.fileLaneProposal(onBoardRooted: store.rootURL, laneID: laneID)?.index
)
guard let slot else { return } // a dead region: hold whatever the create slot already was
session.proposeFile(FileDropTarget(
boardRoot: store.rootURL,
landing: .create(laneID: laneID, index: slot),
fileCount: count
))
}
/// The strip's fall-through for file sessions: which lane is under the cursor, analytically.
@@ -705,6 +702,23 @@ enum FinderDrop {
providers.filter { !isDirectory(typeIdentifiers: $0.registeredTypeIdentifiers) }.count
}
/// How many shadows the create path draws **one nominal-height shadow per incoming file**
/// (04-interactions.md Drag and drop, settled 2026-07-28: the multi-drag precedent), **floored
/// at one**: "when macOS withholds item counts during hover the count floors at one shadow, the
/// commit unaffected".
///
/// The floor is a *hover* concession and nothing more. A drag whose providers cannot be counted
/// still opens a slot the user can aim at, and the write is `FinderDrop.land`'s resolved URLs,
/// partitioned against the filesystem so one shadow standing in for three files costs the drop
/// nothing. It is read from the drag on every sample rather than captured at `dropEntered`: a
/// delegate can be entered without this window ever having seen the enter callback (single-target
/// dispatch hands the session to whichever region is deepest), and a count that was never set
/// would draw the wrong number of shadows.
@MainActor
static func shadowCount(_ providers: [NSItemProvider]) -> Int {
max(1, importableCount(providers))
}
// MARK: The drop read the filesystem
/// Whether `url` is a directory, as the filesystem answers it the authoritative read.
@@ -807,7 +821,10 @@ let boardDropTypes: [UTType] = boardDragTypes + [.fileURL]
/// **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 empty space one card per file.
/// 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