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:
@@ -1704,7 +1704,14 @@ public final class BoardStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Creates one card per file at `index` in `laneID`, each titled with its filename minus the
|
/// Creates one card per file at `index` in `laneID`, each titled with its filename minus the
|
||||||
/// extension and carrying that file as its attachment — the drop-on-empty-space half.
|
/// extension and carrying that file as its attachment — the drop-into-a-lane half.
|
||||||
|
///
|
||||||
|
/// **`index` is the drop position, not the lane's end** (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 … drops are positional everywhere, and
|
||||||
|
/// append-at-bottom stays the creation *trio*'s rule, not the drop's". The gesture resolves it
|
||||||
|
/// through `FileDropZones.landing`; the rank arithmetic below is `moveCards`', so a run landing
|
||||||
|
/// between two siblings takes exactly the ranks a card drop there would have produced.
|
||||||
///
|
///
|
||||||
/// **Ordinary store writes, with no drop-only path**: a fresh GUID and an inserted rank per card
|
/// **Ordinary store writes, with no drop-only path**: a fresh GUID and an inserted rank per card
|
||||||
/// (`Ranks.insertionRanks`, compacting and placing again when midpoint precision is exhausted,
|
/// (`Ranks.insertionRanks`, compacting and placing again when midpoint precision is exhausted,
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import UniformTypeIdentifiers
|
|||||||
|
|
||||||
// MARK: - What each lane draws, as the drag reads it
|
// 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
|
/// Where each lane's card grid and title bar are drawn and how tall its cards are — the measured
|
||||||
/// masonry's drop geometry, one registry per board window.
|
/// 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
|
/// **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
|
/// 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
|
/// are never measured: they are replayed analytically from these heights through
|
||||||
/// `MasonryPlacement.frames(heights:)`, which is the very function `MasonryLayout` places with
|
/// `MasonryPlacement.frames(heights:)`, which is the very function `MasonryLayout` places with
|
||||||
/// (DRAG-REORDER.md § The card masonry).
|
/// (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
|
/// 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.
|
/// `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 grids: [ItemID: Grid] = [:]
|
||||||
private(set) var heights: [ItemID: CGFloat] = [:]
|
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 update(_ grid: Grid, for laneID: ItemID) { grids[laneID] = grid }
|
||||||
func removeGrid(_ laneID: ItemID) { grids.removeValue(forKey: laneID) }
|
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 update(height: CGFloat, for cardID: ItemID) { heights[cardID] = height }
|
||||||
func removeHeight(_ cardID: ItemID) { heights.removeValue(forKey: cardID) }
|
func removeHeight(_ cardID: ItemID) { heights.removeValue(forKey: cardID) }
|
||||||
}
|
}
|
||||||
@@ -303,40 +315,27 @@ struct BoardDropContext {
|
|||||||
acceptsFileDrops && FinderDrop.importableCount(info.itemProviders(for: [.fileURL])) > 0
|
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`,
|
/// Where a **file** session would land in `laneID` — the file mode's twin of `retargetCards`,
|
||||||
/// resolved against the very same analytic masonry geometry.
|
/// 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
|
/// **Created cards land at the drop position** (04-interactions.md ▸ Drag and drop, settled
|
||||||
/// create, anywhere on the card's bounds. The bounds are the resting frames
|
/// 2026-07-28): "resolved through the same card-grid zones an ordinary card drag uses, shadow
|
||||||
/// `MasonryPlacement.frames(heights:)` replays — the same reconstruction the card-slot zones
|
/// included — drops are positional everywhere, and append-at-bottom stays the creation *trio*'s
|
||||||
/// are built from, never a measured frame (03-board-ui.md § Motion). While a create shadow is
|
/// rule, not the drop's."
|
||||||
/// 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.
|
|
||||||
///
|
///
|
||||||
/// **Positional landing is filed for design ratification.** 04's bullet says only "dropped on
|
/// **The landing shadow is the create path's whole feedback** (settled, same bullet): no lane-level
|
||||||
/// lane empty space → creates a card"; that the card lands at the *drop position* rather than at
|
/// highlight is proposed here or drawn anywhere, because "each target gets one clear signal, and
|
||||||
/// the lane's bottom is this milestone's reading of the pathfinder's `retargetFile` precedent,
|
/// the card-attach highlight exists precisely because that target has no shadow".
|
||||||
/// implemented here and awaiting the design's word.
|
///
|
||||||
|
/// 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) {
|
func retargetFile(inLane laneID: ItemID, info: DropInfo) {
|
||||||
guard acceptsFileDrop(info), let cursor = globalCursor(),
|
guard acceptsFileDrop(info), let cursor = globalCursor(),
|
||||||
let lane = store.snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }),
|
let lane = store.snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }),
|
||||||
@@ -355,37 +354,35 @@ struct BoardDropContext {
|
|||||||
spacing: grid.spacing,
|
spacing: grid.spacing,
|
||||||
origin: grid.frame.origin
|
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
|
let landing = FileDropZones.landing(
|
||||||
// match wins, so the answer is deterministic however the frames abut.
|
cursor: cursor,
|
||||||
let frames = placement.frames(heights: heights)
|
headerBottom: registry.headers[laneID]?.maxY,
|
||||||
if let index = frames.firstIndex(where: { frame in
|
placement: placement,
|
||||||
cursor.x >= frame.minX && cursor.x <= frame.maxX
|
heights: heights,
|
||||||
&& cursor.y >= frame.minY && cursor.y <= frame.maxY
|
nominalHeight: LaneDropRegistry.nominalCardHeight,
|
||||||
}), index < rendered.count {
|
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(
|
session.proposeFile(FileDropTarget(
|
||||||
boardRoot: store.rootURL,
|
boardRoot: store.rootURL,
|
||||||
landing: .attach(cardID: rendered[index].id),
|
landing: .attach(cardID: rendered[index].id),
|
||||||
fileCount: count
|
fileCount: count
|
||||||
))
|
))
|
||||||
return
|
case let .create(index):
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
session.proposeFile(FileDropTarget(
|
||||||
boardRoot: store.rootURL,
|
boardRoot: store.rootURL,
|
||||||
landing: .create(laneID: laneID, index: slot),
|
landing: .create(laneID: laneID, index: index),
|
||||||
fileCount: count
|
fileCount: count
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The strip's fall-through for file sessions: which lane is under the cursor, analytically.
|
/// 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
|
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
|
// MARK: The drop read — the filesystem
|
||||||
|
|
||||||
/// Whether `url` is a directory, as the filesystem answers it — the authoritative read.
|
/// 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
|
/// **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
|
/// 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
|
/// 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 {
|
struct LaneDropDelegate: DropDelegate {
|
||||||
|
|
||||||
let context: BoardDropContext
|
let context: BoardDropContext
|
||||||
|
|||||||
@@ -109,9 +109,18 @@ struct FileDropTarget: Equatable, Sendable {
|
|||||||
enum Landing: Equatable, Sendable {
|
enum Landing: Equatable, Sendable {
|
||||||
/// **Onto a card**: the files copy into that card's `attachments/`. A card under the cursor
|
/// **Onto a card**: the files copy into that card's `attachments/`. A card under the cursor
|
||||||
/// always wins over the lane behind it — attach beats create, anywhere on the card's bounds.
|
/// always wins over the lane behind it — attach beats create, anywhere on the card's bounds.
|
||||||
|
/// This is the one file landing that **highlights**, and only because it has no shadow to
|
||||||
|
/// show instead (04-interactions.md ▸ Drag and drop, settled 2026-07-28).
|
||||||
case attach(cardID: ItemID)
|
case attach(cardID: ItemID)
|
||||||
/// **Onto lane empty space**: one card per file, landing at this position in the lane's
|
/// **Onto the lane's card grid**: one card per file, landing at this position in the lane's
|
||||||
/// logical card order.
|
/// logical card order.
|
||||||
|
///
|
||||||
|
/// **Positional, everywhere the grid reaches** (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 a release on the lane
|
||||||
|
/// **header** resolves to index 0, the topmost position. Append-at-bottom is the creation
|
||||||
|
/// trio's rule (⌘N, Return, a double click), never the drop's. `FileDropZones.landing` is
|
||||||
|
/// where the index comes from.
|
||||||
case create(laneID: ItemID, index: Int)
|
case create(laneID: ItemID, index: Int)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,12 +130,15 @@ struct FileDropTarget: Equatable, Sendable {
|
|||||||
|
|
||||||
var landing: Landing
|
var landing: Landing
|
||||||
|
|
||||||
/// How many files ride along — the create path's shadow run length, one shadow per card that
|
/// How many files ride along — **one nominal-height shadow per incoming file** on the create path
|
||||||
/// will land. Read off the session's item providers at hover time, floored at one.
|
/// (04-interactions.md ▸ Drag and drop, settled 2026-07-28: the multi-drag precedent), one shadow
|
||||||
|
/// per card that will land. Read off the session's item providers at hover time
|
||||||
|
/// (`FinderDrop.shadowCount`), **floored at one** for the drag whose count macOS withholds — the
|
||||||
|
/// commit is unaffected either way, since the write counts resolved URLs, not providers.
|
||||||
///
|
///
|
||||||
/// **Folders are not counted** (04-interactions.md ▸ Drag and drop: "a mixed drag proposes for
|
/// **Folders are not counted** (same bullet: "a mixed drag proposes for its files only"):
|
||||||
/// its files only"): `FinderDrop.importableCount` reads the providers' declared types, so a
|
/// `FinderDrop.importableCount` reads the providers' declared types, so a mixed drag draws
|
||||||
/// mixed drag draws shadows for its files alone and a folders-only drag never proposes at all.
|
/// shadows for its files alone and a folders-only drag never proposes at all.
|
||||||
var fileCount: Int
|
var fileCount: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,6 +580,15 @@ final class DragSession {
|
|||||||
|
|
||||||
/// The shadow run a file drop would open in `laneID` on this board — where the created cards
|
/// The shadow run a file drop would open in `laneID` on this board — where the created cards
|
||||||
/// land and how many there are — or `nil` when the proposal is elsewhere.
|
/// land and how many there are — or `nil` when the proposal is elsewhere.
|
||||||
|
///
|
||||||
|
/// **This run is the create path's whole feedback** (04-interactions.md ▸ Drag and drop, settled
|
||||||
|
/// 2026-07-28): there is no lane-level highlight to go with it, deliberately — each target gets
|
||||||
|
/// one clear signal, and `fileAttachTarget` above is the highlight precisely because a card
|
||||||
|
/// target has no shadow.
|
||||||
|
///
|
||||||
|
/// The floor restates `FinderDrop.shadowCount`'s, at the render end rather than in place of it: a
|
||||||
|
/// proposal that somehow carried a zero would otherwise open a run of no shadows at all, which is
|
||||||
|
/// a landing spot the user cannot see.
|
||||||
func fileLaneProposal(onBoardRooted root: URL, laneID: ItemID) -> (index: Int, count: Int)? {
|
func fileLaneProposal(onBoardRooted root: URL, laneID: ItemID) -> (index: Int, count: Int)? {
|
||||||
guard let fileTarget,
|
guard let fileTarget,
|
||||||
DragLocality.isSameBoard(fileTarget.boardRoot, root),
|
DragLocality.isSameBoard(fileTarget.boardRoot, root),
|
||||||
|
|||||||
@@ -285,3 +285,85 @@ enum DropSlotMath {
|
|||||||
return remaining
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -132,6 +132,15 @@ struct LaneView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onDrag(startLaneDrag, preview: { dragReplica })
|
.onDrag(startLaneDrag, preview: { dragReplica })
|
||||||
|
// **The header is the file drop's 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"). Its frame is registered rather than derived from the
|
||||||
|
// grid's because the grid is scroll-view *content*: scrolled down, its top edge climbs
|
||||||
|
// past the header and would stop being a boundary at all. The bar itself never moves.
|
||||||
|
.onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { frame in
|
||||||
|
drops.registry.update(header: frame, for: lane.id)
|
||||||
|
}
|
||||||
|
.onDisappear { drops.registry.removeHeader(lane.id) }
|
||||||
.overlay(alignment: .trailing) { newCardButton }
|
.overlay(alignment: .trailing) { newCardButton }
|
||||||
.contextMenu { laneMenu }
|
.contextMenu { laneMenu }
|
||||||
// The lane's half of the Style… popover. Anchored on the header because that is the
|
// The lane's half of the Style… popover. Anchored on the header because that is the
|
||||||
|
|||||||
@@ -468,6 +468,128 @@ struct CardSlotTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - A Finder file drag's zones
|
||||||
|
|
||||||
|
/// `FileDropZones` — **"created cards land at the drop position"** (04-interactions.md ▸ Drag and
|
||||||
|
/// drop, settled 2026-07-28), pinned on the same fixture the card zones are pinned on, because that
|
||||||
|
/// is the claim: a file drop resolves through *the same card-grid zones an ordinary card drag uses*.
|
||||||
|
///
|
||||||
|
/// The lane, exactly as `CardSlotTests` reads it — 2 columns, 100pt wide, 8pt spacing, origin (0, 0):
|
||||||
|
/// column 0 (x 0…100): card 0 [0, 40] · card 2 [48, 78] · card 4 [86, 136]
|
||||||
|
/// column 1 (x 108…208): card 1 [0, 60] · card 3 [68, 88]
|
||||||
|
/// Column bands meet at 104. The incoming cards have no measured height, so the zones are capped at
|
||||||
|
/// the nominal one — `LaneDropRegistry.nominalCardHeight`, the same stand-in a cross-board arrival
|
||||||
|
/// gets.
|
||||||
|
/// `@MainActor` for one reason: `LaneDropRegistry.nominalCardHeight` is the app's own answer to "how
|
||||||
|
/// tall is a card nobody has measured", and reading it here — rather than repeating the number — is
|
||||||
|
/// what keeps these zones pinned to the height the shadows actually draw at.
|
||||||
|
@MainActor
|
||||||
|
@Suite("FileDropZones ▸ a Finder file drag's landing")
|
||||||
|
struct FileDropZoneTests {
|
||||||
|
private let placement = MasonryPlacement(columnCount: 2, columnWidth: 100, spacing: 8)
|
||||||
|
private let heights: [CGFloat] = [40, 60, 30, 20, 50]
|
||||||
|
private let nominal = LaneDropRegistry.nominalCardHeight
|
||||||
|
|
||||||
|
private func landing(
|
||||||
|
_ x: CGFloat, _ y: CGFloat, headerBottom: CGFloat? = nil, current: Int? = nil,
|
||||||
|
heights: [CGFloat]? = nil
|
||||||
|
) -> FileDropZones.Landing {
|
||||||
|
FileDropZones.landing(
|
||||||
|
cursor: CGPoint(x: x, y: y), headerBottom: headerBottom, placement: placement,
|
||||||
|
heights: heights ?? self.heights, nominalHeight: nominal, current: current)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every probe below that is **not** over a card, so the create branch is the one answering.
|
||||||
|
private let emptyProbes: [(CGFloat, CGFloat, Int)] = [
|
||||||
|
(20, 150, 5), // below column 0's last card — the end slot
|
||||||
|
(150, 150, 5), // below column 1's last card — the end slot too
|
||||||
|
(20, 82, 4), // the gap between card 2 and card 4, in column 0
|
||||||
|
(150, 64, 3), // the gap between card 1 and card 3, in column 1
|
||||||
|
(-60, 20, 0), // the lane's leading padding: still column 0
|
||||||
|
(400, 20, 1), // and its trailing padding: column 1
|
||||||
|
(104, 30, 1), // the gutter between the columns, which the band rule gives to column 1
|
||||||
|
]
|
||||||
|
|
||||||
|
@Test("The create slot is the card-drag zone, at the incoming run's nominal footprint")
|
||||||
|
func createIsTheCardZone() {
|
||||||
|
for (x, y, expected) in emptyProbes {
|
||||||
|
#expect(landing(x, y) == .create(index: expected), "(\(x), \(y))")
|
||||||
|
// The claim itself: not "an index like the card zones'" but *the card zones'* answer.
|
||||||
|
let card = DropSlotMath.cardSlot(
|
||||||
|
cursor: CGPoint(x: x, y: y), placement: placement, heights: heights,
|
||||||
|
draggedHeight: nominal, current: nil)
|
||||||
|
#expect(landing(x, y) == .create(index: card ?? -1),
|
||||||
|
"(\(x), \(y)) must be exactly what an ordinary card drag proposes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A card under the cursor attaches — anywhere on its bounds, closed at the edges")
|
||||||
|
func attachBeatsCreate() {
|
||||||
|
let probes: [(CGFloat, CGFloat, Int)] = [
|
||||||
|
(20, 20, 0), (150, 20, 1), (20, 60, 2), (150, 75, 3), (20, 100, 4),
|
||||||
|
]
|
||||||
|
for (x, y, expected) in probes {
|
||||||
|
for current in [nil, 0, 1, 2, 3, 4, 5] {
|
||||||
|
#expect(landing(x, y, current: current) == .attach(index: expected),
|
||||||
|
"(\(x), \(y)) with current \(String(describing: current))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Closed containment: a cursor exactly on a shared edge still counts, and the first match
|
||||||
|
// wins, so the answer is deterministic however the frames abut.
|
||||||
|
#expect(landing(100, 40) == .attach(index: 0))
|
||||||
|
#expect(landing(108, 0) == .attach(index: 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **"A release on the lane header resolves to the topmost position"** (04-interactions.md,
|
||||||
|
/// settled 2026-07-28) — forgiving beats a dead stripe.
|
||||||
|
@Test("The lane header is the topmost position, whatever column the cursor is over")
|
||||||
|
func headerIsTheTopmostPosition() {
|
||||||
|
for x: CGFloat in [-60, 20, 104, 150, 400] {
|
||||||
|
for y: CGFloat in [-500, -40, -20] {
|
||||||
|
#expect(landing(x, y, headerBottom: -20) == .create(index: 0), "(\(x), \(y))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The edge is the header's own, and one point below it the masonry answers again — which is
|
||||||
|
// column 1's first row, the very reading the rule exists to override.
|
||||||
|
#expect(landing(150, -20, headerBottom: -20) == .create(index: 0))
|
||||||
|
#expect(landing(150, -19, headerBottom: -20) == .create(index: 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Without the header rule the stripe reads as a column, which is why the rule exists")
|
||||||
|
func noHeaderFrameLetsTheMasonryAnswer() {
|
||||||
|
// A lane whose header has not laid out yet: the masonry clamps inward to the nearest column,
|
||||||
|
// so the same cursor proposes column 1's first row rather than the top of the lane.
|
||||||
|
#expect(landing(150, -40, headerBottom: nil) == .create(index: 1))
|
||||||
|
#expect(landing(20, -40, headerBottom: nil) == .create(index: 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The header is asked first, so a scrolled masonry cannot hide the stripe behind a card")
|
||||||
|
func headerWinsOverACardBehindIt() {
|
||||||
|
// The masonry is scroll-view content: scrolled down, a card's resting frame can compute to a
|
||||||
|
// y the header stripe occupies. The ruling admits no exception, so the header answers.
|
||||||
|
#expect(landing(150, 20) == .attach(index: 1), "with no header the card takes it")
|
||||||
|
#expect(landing(150, 20, headerBottom: 50) == .create(index: 0))
|
||||||
|
#expect(landing(20, 20, headerBottom: 50) == .create(index: 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A dead region holds, and with nothing to hold the containing zone answers")
|
||||||
|
func deadRegionHolds() {
|
||||||
|
// A 200pt card in column 1 and the cursor in the gutter beside its far side: the nominal
|
||||||
|
// footprint (44) does not reach there, so the zone is dead.
|
||||||
|
let tall: [CGFloat] = [40, 200]
|
||||||
|
#expect(landing(104, 150, current: 1, heights: tall) == .hold)
|
||||||
|
#expect(landing(104, 150, current: nil, heights: tall) == .create(index: 1),
|
||||||
|
"a fresh entry must still have a landing spot")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An empty lane takes the drop at its only position, header or not")
|
||||||
|
func emptyLane() {
|
||||||
|
#expect(landing(10, 10, heights: []) == .create(index: 0))
|
||||||
|
#expect(landing(400, 900, heights: []) == .create(index: 0))
|
||||||
|
#expect(landing(150, -40, headerBottom: -20, heights: []) == .create(index: 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Applying a proposal
|
// MARK: - Applying a proposal
|
||||||
|
|
||||||
@Suite("DropSlotMath ▸ applying a proposal")
|
@Suite("DropSlotMath ▸ applying a proposal")
|
||||||
|
|||||||
@@ -278,6 +278,42 @@ struct CreateCardsFromFilesTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **"Created cards land at the drop position — resolved through the same card-grid zones an
|
||||||
|
/// ordinary card drag uses"** (04-interactions.md ▸ Drag and drop, settled 2026-07-28). The
|
||||||
|
/// gesture's half of that is `FileDropZones`; the *write*'s half is this: landing at index N must
|
||||||
|
/// produce the ranks a card dropped at index N would have produced, not merely an order that
|
||||||
|
/// happens to read right.
|
||||||
|
@Test("A file landing at index N takes the very rank a card dropped at index N would take")
|
||||||
|
func ranksMatchACardDropAtTheSameIndex() throws {
|
||||||
|
for index in 0...3 {
|
||||||
|
// The file drop: one card minted at `index` in Todo.
|
||||||
|
let dropped = try makeBoard()
|
||||||
|
defer { dropped.tearDown() }
|
||||||
|
let sources = try DropSources()
|
||||||
|
defer { sources.tearDown() }
|
||||||
|
let fileStore = try BoardStore(rootURL: dropped.root)
|
||||||
|
fileStore.createCards(fromFiles: [try sources.file("dropped.txt")], inLane: lane1, at: index)
|
||||||
|
|
||||||
|
// The card drop: Fourth dragged out of Doing into the same slot of the same lane. Its
|
||||||
|
// neighbours are identical, so a shared rank arithmetic must answer identically.
|
||||||
|
let moved = try makeBoard()
|
||||||
|
defer { moved.tearDown() }
|
||||||
|
let cardStore = try BoardStore(rootURL: moved.root)
|
||||||
|
cardStore.moveCards([ItemID(rawValue: Ident.card4)], toLane: lane1, at: index)
|
||||||
|
|
||||||
|
let droppedCards = try cards(lane1, in: dropped)
|
||||||
|
let movedCards = try cards(lane1, in: moved)
|
||||||
|
#expect(droppedCards.map(\.title.value)
|
||||||
|
== movedCards.map { $0.title.value == "Fourth" ? "dropped" : $0.title.value },
|
||||||
|
"index \(index): the run lands in the same position")
|
||||||
|
#expect(droppedCards.map(\.order) == movedCards.map(\.order),
|
||||||
|
"index \(index): and takes the same ranks")
|
||||||
|
#expect(droppedCards[index].order == movedCards[index].order)
|
||||||
|
#expect(fileStore.banners.oneShots.isEmpty)
|
||||||
|
#expect(cardStore.banners.oneShots.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test("An empty lane takes the drop at its only position")
|
@Test("An empty lane takes the drop at its only position")
|
||||||
func emptyLane() throws {
|
func emptyLane() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
@@ -500,6 +536,18 @@ struct FinderDropFolderTests {
|
|||||||
@Suite("Finder drop ▸ what the drag is carrying")
|
@Suite("Finder drop ▸ what the drag is carrying")
|
||||||
struct FinderDropPayloadTests {
|
struct FinderDropPayloadTests {
|
||||||
|
|
||||||
|
/// A provider announcing one declared type and nothing else — the hover read's whole input.
|
||||||
|
@MainActor
|
||||||
|
private func provider(_ type: UTType) -> NSItemProvider {
|
||||||
|
let provider = NSItemProvider()
|
||||||
|
provider.registerDataRepresentation(forTypeIdentifier: type.identifier, visibility: .all) {
|
||||||
|
completion in
|
||||||
|
completion(Data(), nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return provider
|
||||||
|
}
|
||||||
|
|
||||||
@Test("Conformance to public.directory is the test — folders and packages alike")
|
@Test("Conformance to public.directory is the test — folders and packages alike")
|
||||||
func directoryTypes() {
|
func directoryTypes() {
|
||||||
#expect(FinderDrop.isDirectory(typeIdentifiers: ["public.folder", "public.file-url"]))
|
#expect(FinderDrop.isDirectory(typeIdentifiers: ["public.folder", "public.file-url"]))
|
||||||
@@ -526,16 +574,6 @@ struct FinderDropPayloadTests {
|
|||||||
@MainActor
|
@MainActor
|
||||||
@Test("The importable count is the file count — folders are not counted, so they draw no shadow")
|
@Test("The importable count is the file count — folders are not counted, so they draw no shadow")
|
||||||
func importableCountCountsFilesOnly() {
|
func importableCountCountsFilesOnly() {
|
||||||
func provider(_ type: UTType) -> NSItemProvider {
|
|
||||||
let provider = NSItemProvider()
|
|
||||||
provider.registerDataRepresentation(forTypeIdentifier: type.identifier, visibility: .all) {
|
|
||||||
completion in
|
|
||||||
completion(Data(), nil)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return provider
|
|
||||||
}
|
|
||||||
|
|
||||||
#expect(FinderDrop.importableCount([provider(.png), provider(.folder)]) == 1)
|
#expect(FinderDrop.importableCount([provider(.png), provider(.folder)]) == 1)
|
||||||
#expect(FinderDrop.importableCount([provider(.png), provider(.plainText)]) == 2)
|
#expect(FinderDrop.importableCount([provider(.png), provider(.plainText)]) == 2)
|
||||||
// Zero is the refusal itself: `acceptsFileDrop` is false, so the drag never engages.
|
// Zero is the refusal itself: `acceptsFileDrop` is false, so the drag never engages.
|
||||||
@@ -543,6 +581,30 @@ struct FinderDropPayloadTests {
|
|||||||
#expect(FinderDrop.importableCount([]) == 0)
|
#expect(FinderDrop.importableCount([]) == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **"One nominal-height shadow per incoming file"** (04-interactions.md ▸ Drag and drop, settled
|
||||||
|
/// 2026-07-28 — the multi-drag precedent), **"and when macOS withholds item counts during hover
|
||||||
|
/// the count floors at one shadow, the commit unaffected"**.
|
||||||
|
@MainActor
|
||||||
|
@Test("The shadow run is one shadow per importable file, floored at one")
|
||||||
|
func shadowCountIsTheImportableCountFlooredAtOne() {
|
||||||
|
#expect(FinderDrop.shadowCount([provider(.png)]) == 1)
|
||||||
|
#expect(FinderDrop.shadowCount([provider(.png), provider(.plainText)]) == 2)
|
||||||
|
#expect(FinderDrop.shadowCount([provider(.png), provider(.plainText), provider(.pdf)]) == 3)
|
||||||
|
// A mixed drag's shadows agree with what will actually be minted: files only.
|
||||||
|
#expect(FinderDrop.shadowCount([provider(.png), provider(.folder), provider(.folder)]) == 1)
|
||||||
|
|
||||||
|
// The floor. A drag whose providers the system will not count still opens a slot the user can
|
||||||
|
// aim at; the write counts resolved URLs, so a lone shadow standing in for a whole drag costs
|
||||||
|
// the drop nothing.
|
||||||
|
#expect(FinderDrop.shadowCount([]) == 1)
|
||||||
|
#expect(FinderDrop.shadowCount([provider(.folder), provider(.folder)]) == 1)
|
||||||
|
|
||||||
|
// Everywhere the count is real, it is exactly the importable count.
|
||||||
|
for payload in [[provider(.png)], [provider(.png), provider(.folder), provider(.plainText)]] {
|
||||||
|
#expect(FinderDrop.shadowCount(payload) == FinderDrop.importableCount(payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test("The drop reads the filesystem, which is the authority a declared type is not")
|
@Test("The drop reads the filesystem, which is the authority a declared type is not")
|
||||||
func filesystemIsAuthoritative() throws {
|
func filesystemIsAuthoritative() throws {
|
||||||
let sources = try DropSources()
|
let sources = try DropSources()
|
||||||
|
|||||||
Reference in New Issue
Block a user