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:
@@ -285,3 +285,85 @@ enum DropSlotMath {
|
||||
return remaining
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - A Finder file drag's zones
|
||||
|
||||
/// Where an external **Finder file** drag resolves inside one lane, as pure arithmetic
|
||||
/// (`FileDropZoneTests`) — the three answers a lane's own geometry gives it.
|
||||
///
|
||||
/// **Drops are positional everywhere** (04-interactions.md ▸ Drag and drop, settled 2026-07-28):
|
||||
/// "created cards land at the drop position — resolved through the same card-grid zones an ordinary
|
||||
/// card drag uses, shadow included", and append-at-bottom stays the creation *trio*'s rule (⌘N,
|
||||
/// Return, a double click on empty space), not the drop's. So the create landing is
|
||||
/// `DropSlotMath.cardSlot` and nothing else: the very function a card drag proposes through, with the
|
||||
/// incoming run's **nominal** footprint standing in for the frozen height a card drag freezes at
|
||||
/// pickup — the cards being proposed do not exist yet to have been measured, exactly as a cross-board
|
||||
/// arrival's do not.
|
||||
///
|
||||
/// Kept out of `BoardDropContext` for `TrashDrop`'s reason: the ruling is then checkable without a
|
||||
/// window, and the hover and the release read one answer rather than two that can drift.
|
||||
enum FileDropZones {
|
||||
|
||||
/// What the lane's geometry says the files would become.
|
||||
enum Landing: Equatable, Sendable {
|
||||
/// The cursor is over the card at this position in the lane's **logical** card order — the
|
||||
/// files join its `attachments/`. Attach beats create anywhere on a card's bounds.
|
||||
case attach(index: Int)
|
||||
/// One card per file, opening at this position in the logical card order.
|
||||
case create(index: Int)
|
||||
/// A dead region (`DropSlotMath.slot`'s `nil`): **hold** whatever the create slot already was.
|
||||
case hold
|
||||
}
|
||||
|
||||
/// Resolves `cursor` against one lane, in three questions asked in this order.
|
||||
///
|
||||
/// 1. **The lane header is the topmost position** (04-interactions.md ▸ Drag and drop, settled
|
||||
/// 2026-07-28: "a release on the lane header resolves to the topmost position — forgiving beats
|
||||
/// a dead stripe: the header's chrome roles don't collide with a file payload"). The header
|
||||
/// does not scroll, so its own edge is the honest boundary; the accent band and the plate's top
|
||||
/// padding sit above it and are its chrome, which is why the test is *at or above* rather than
|
||||
/// containment. It is asked **first** because a scrolled masonry can place a card's analytic
|
||||
/// frame behind the header stripe, and the ruling admits no exception there.
|
||||
/// 2. **A card under the cursor always wins** over the lane behind it. The bounds are the resting
|
||||
/// frames `MasonryPlacement.frames(heights:)` replays — the same reconstruction the slot zones
|
||||
/// are built from, never a measured frame (03-board-ui.md § Motion). Closed containment, first
|
||||
/// match wins, so the answer is deterministic however the frames abut.
|
||||
/// 3. **Otherwise the create slot**, from `DropSlotMath.cardSlot`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - cursor: the pointer, in `placement.origin`'s space.
|
||||
/// - headerBottom: the lane header's bottom edge in that same space, or `nil` for a lane whose
|
||||
/// header has not registered a frame yet — where the masonry answers alone.
|
||||
/// - placement: the lane's resting grid geometry.
|
||||
/// - heights: the lane's rendered cards' heights, in logical order.
|
||||
/// - nominalHeight: the height an incoming, unmeasured card is assumed to have — the span the
|
||||
/// create zone's cap is measured against, and the height each shadow draws at.
|
||||
/// - current: the create slot currently proposed for this lane, or `nil`.
|
||||
static func landing(
|
||||
cursor: CGPoint,
|
||||
headerBottom: CGFloat?,
|
||||
placement: MasonryPlacement,
|
||||
heights: [CGFloat],
|
||||
nominalHeight: CGFloat,
|
||||
current: Int?
|
||||
) -> Landing {
|
||||
if let headerBottom, cursor.y <= headerBottom { return .create(index: 0) }
|
||||
|
||||
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
|
||||
}) {
|
||||
return .attach(index: index)
|
||||
}
|
||||
|
||||
guard let slot = DropSlotMath.cardSlot(
|
||||
cursor: cursor,
|
||||
placement: placement,
|
||||
heights: heights,
|
||||
draggedHeight: nominalHeight,
|
||||
current: current
|
||||
) else { return .hold }
|
||||
return .create(index: slot)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user