diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 0d1ca14..df457a4 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -1704,7 +1704,14 @@ public final class BoardStore { } /// 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 /// (`Ranks.insertionRanks`, compacting and placing again when midpoint precision is exhausted, diff --git a/Kanban/UI/Board/BoardDrops.swift b/Kanban/UI/Board/BoardDrops.swift index b24a81a..d208aa5 100644 --- a/Kanban/UI/Board/BoardDrops.swift +++ b/Kanban/UI/Board/BoardDrops.swift @@ -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 diff --git a/Kanban/UI/Board/DragSession.swift b/Kanban/UI/Board/DragSession.swift index f37cde2..3871a2f 100644 --- a/Kanban/UI/Board/DragSession.swift +++ b/Kanban/UI/Board/DragSession.swift @@ -109,9 +109,18 @@ struct FileDropTarget: Equatable, Sendable { enum Landing: Equatable, Sendable { /// **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. + /// 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) - /// **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. + /// + /// **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) } @@ -121,12 +130,15 @@ struct FileDropTarget: Equatable, Sendable { var landing: Landing - /// How many files ride along — the create path's shadow run length, one shadow per card that - /// will land. Read off the session's item providers at hover time, floored at one. + /// How many files ride along — **one nominal-height shadow per incoming file** on the create path + /// (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 - /// its files only"): `FinderDrop.importableCount` reads the providers' declared types, so a - /// mixed drag draws shadows for its files alone and a folders-only drag never proposes at all. + /// **Folders are not counted** (same bullet: "a mixed drag proposes for its files only"): + /// `FinderDrop.importableCount` reads the providers' declared types, so a mixed drag draws + /// shadows for its files alone and a folders-only drag never proposes at all. 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 /// 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)? { guard let fileTarget, DragLocality.isSameBoard(fileTarget.boardRoot, root), diff --git a/Kanban/UI/Board/DropSlotMath.swift b/Kanban/UI/Board/DropSlotMath.swift index 1da9968..d40f6e9 100644 --- a/Kanban/UI/Board/DropSlotMath.swift +++ b/Kanban/UI/Board/DropSlotMath.swift @@ -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) + } +} diff --git a/Kanban/UI/Board/LaneView.swift b/Kanban/UI/Board/LaneView.swift index 0239dbb..866960e 100644 --- a/Kanban/UI/Board/LaneView.swift +++ b/Kanban/UI/Board/LaneView.swift @@ -132,6 +132,15 @@ struct LaneView: View { ) } .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 } .contextMenu { laneMenu } // The lane's half of the Style… popover. Anchored on the header because that is the diff --git a/KanbanTests/DropSlotMathTests.swift b/KanbanTests/DropSlotMathTests.swift index 0e7da90..1e7f1b4 100644 --- a/KanbanTests/DropSlotMathTests.swift +++ b/KanbanTests/DropSlotMathTests.swift @@ -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 @Suite("DropSlotMath ▸ applying a proposal") diff --git a/KanbanTests/FileDropWriteTests.swift b/KanbanTests/FileDropWriteTests.swift index ac627c3..d144cbe 100644 --- a/KanbanTests/FileDropWriteTests.swift +++ b/KanbanTests/FileDropWriteTests.swift @@ -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") func emptyLane() throws { let fixture = try makeBoard() @@ -500,6 +536,18 @@ struct FinderDropFolderTests { @Suite("Finder drop ▸ what the drag is carrying") 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") func directoryTypes() { #expect(FinderDrop.isDirectory(typeIdentifiers: ["public.folder", "public.file-url"])) @@ -526,16 +574,6 @@ struct FinderDropPayloadTests { @MainActor @Test("The importable count is the file count — folders are not counted, so they draw no shadow") 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(.plainText)]) == 2) // Zero is the refusal itself: `acceptsFileDrop` is false, so the drag never engages. @@ -543,6 +581,30 @@ struct FinderDropPayloadTests { #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") func filesystemIsAuthoritative() throws { let sources = try DropSources()