Drop a card on the shown trash to delete it
04's ruling makes the drag the pointer's delete gesture: the shown trash column accepts live same-board card drags, the shadow pinned topmost — honest, since the trash sorts by deleted newest-first — and release tombstones through the same write path as Backspace, extracted so the two gestures cannot drift. DropTarget grew a container case for the quasi-lane (it has no lane id by construction); lane drags, cross-board arrivals, option-copies (re-checked at release, the one input that can flip without a callback), trashed-side payloads, hidden trash, and the read-only lock all refuse — and a refusal falls through to the strip retarget, never cancelling the drag. The settle draws the tombstoned rows in the trash under the cards' own GUIDs, so the echo is an invisible content swap and nothing winks out for a round trip. Selection needs no surgery: the reload's resolve rule ejects tombstoned members as the vanish it is, pinned by a test contrasting both gestures. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -7,16 +7,91 @@ import SwiftUI
|
||||
|
||||
/// The drop proposal: which board, which container, which slot.
|
||||
///
|
||||
/// One value for both layouts, because they differ only in what the container is: `laneID == nil`
|
||||
/// is the **lane strip** (the index counts live lanes with the dragged run removed), and a lane id
|
||||
/// is that lane's **masonry** (the index is a position in its logical card order — DRAG-REORDER.md
|
||||
/// § The card masonry). `boardRoot` is what makes a proposal cross-board-aware: only the board whose
|
||||
/// root it names renders the shadows, and only that board's delegate may commit it.
|
||||
/// One value for every layout, because they differ only in what the container is, and `boardRoot` is
|
||||
/// what makes a proposal cross-board-aware: only the board whose root it names renders the shadows,
|
||||
/// and only that board's delegate may commit it.
|
||||
struct DropTarget: Equatable, Sendable {
|
||||
|
||||
/// The three surfaces a drop can name, spelled as a sum so the impossible combinations cannot be
|
||||
/// written down at all.
|
||||
///
|
||||
/// The trash is a case rather than an id because **it has no id**: the quasi-lane is not in the
|
||||
/// snapshot — it is `TrashModel.entries` derived from it — so there is nothing to put in a
|
||||
/// `lane`, and its index is not a position the pointer chose either (see `TrashDrop`).
|
||||
enum Container: Equatable, Sendable {
|
||||
/// The **lane strip**: the index counts live lanes with the dragged run removed.
|
||||
case strip
|
||||
/// That lane's **masonry**: the index is a position in its logical card order
|
||||
/// (DRAG-REORDER.md § The card masonry).
|
||||
case lane(ItemID)
|
||||
/// The **trash quasi-lane**, which a live card drag proposes into to delete it
|
||||
/// (04-interactions.md ▸ The trash, settled 2026-07-28). The index is always the topmost row.
|
||||
case trash
|
||||
}
|
||||
|
||||
var boardRoot: URL
|
||||
/// `nil` == the lane strip.
|
||||
var laneID: ItemID?
|
||||
var container: Container
|
||||
var index: Int
|
||||
|
||||
/// The lane this proposal names, or `nil` for the strip and the trash — the shape the container
|
||||
/// wore before there were three of them, kept because most readers only ask this one question.
|
||||
var laneID: ItemID? {
|
||||
if case let .lane(id) = container { return id }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Whether this proposal names the trash column, and therefore means *delete*.
|
||||
var isTrash: Bool { container == .trash }
|
||||
}
|
||||
|
||||
// MARK: - Dropping on the trash
|
||||
|
||||
/// **Drop-on-trash deletes** (04-interactions.md ▸ The trash, settled 2026-07-28: "the drag becomes
|
||||
/// the pointer's delete gesture — release tombstones the dragged card(s), exactly the ⌫ tombstone"),
|
||||
/// as the two pure facts the gesture is made of (`TrashDropTests`).
|
||||
///
|
||||
/// Kept out of the drop context so the ruling is checkable without a window, and stated once so the
|
||||
/// **hover** and the **release** cannot disagree about what the trash takes — the hazard being a
|
||||
/// modifier pressed *after* the proposal stood, which no `dropUpdated` need ever report.
|
||||
enum TrashDrop {
|
||||
|
||||
/// The row the shadow takes, always: **the topmost**.
|
||||
///
|
||||
/// Not arbitrary, and the sort is what makes it honest: the trash orders by `deleted`
|
||||
/// newest-first (03-board-ui.md § Trash), so a fresh tombstone genuinely lands on top. The drop
|
||||
/// therefore still lands exactly where the shadow shows — the one positional promise every other
|
||||
/// drop in this app makes — while being the only proposal on the board the *pointer* does not
|
||||
/// choose.
|
||||
static let landingIndex = 0
|
||||
|
||||
/// Whether the shown trash takes this session — the whole of the gate, and every clause is a
|
||||
/// refusal 04 states in its own words:
|
||||
///
|
||||
/// - **Lanes are not deliverable this way** — "a lane drag proposes only lane slots". (The strip's
|
||||
/// slot list has never contained the quasi-lane, so this is belt over braces; it is written down
|
||||
/// because a guard that is only true by construction is one refactor from being false.)
|
||||
/// - **A trash row is already there.** A `.trashed` session's vocabulary is restore and copy-out;
|
||||
/// dropping it back where it came from writes nothing.
|
||||
/// - **Cross-board is refused.** "No move or paste ever targets the trash": a foreign card
|
||||
/// delivered *into* this board's trash would be a transfer-and-delete compound, an operation the
|
||||
/// design gives no name and no undo story. The card stays where it is.
|
||||
/// - **⌥ is refused.** Copying into the trash is not a thing — the copy grammar promises the
|
||||
/// original stays exactly where it was, and there is nothing to tombstone but the original.
|
||||
/// - **Hidden, the trash is invisible to every gesture.** True by construction too (the column is
|
||||
/// not rendered, so it has no drop region), and stated here so the claim is testable.
|
||||
/// - **The mutating-gesture rule**, like every other write the pointer can start.
|
||||
static func accepts(
|
||||
kind: DragKind?,
|
||||
side: Liveness,
|
||||
isWithinBoard: Bool,
|
||||
operation: TransferOperation,
|
||||
isTrashShown: Bool,
|
||||
acceptsMutations: Bool
|
||||
) -> Bool {
|
||||
guard isTrashShown, acceptsMutations else { return false }
|
||||
guard kind == .cards, side == .live, isWithinBoard else { return false }
|
||||
return operation == .move
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Where an external file drag would land
|
||||
@@ -374,6 +449,10 @@ final class DragSession {
|
||||
/// them, because the write really did take them away — the overlay draws them at their landing
|
||||
/// slot instead, which is the whole of "rendering the arrangement means rendering the card"
|
||||
/// (03-board-ui.md § Motion).
|
||||
///
|
||||
/// **A drop on the trash is a move by this rule and needs no clause of its own**: the tombstone
|
||||
/// really did take the cards off the live side, and the landing slot the overlay draws them at is
|
||||
/// the trash's topmost row (`trashLanding`).
|
||||
func hiddenMembers(onBoardRooted root: URL) -> Set<ItemID> {
|
||||
guard isActive, side == .live, let sourceRoot,
|
||||
DragLocality.isSameBoard(root, sourceRoot)
|
||||
@@ -385,7 +464,7 @@ final class DragSession {
|
||||
/// The proposal's index when it names this board's strip, else `nil` — the lane strip's shadow
|
||||
/// run position.
|
||||
func stripProposal(onBoardRooted root: URL) -> Int? {
|
||||
guard kind == .lanes, let proposal, proposal.laneID == nil,
|
||||
guard kind == .lanes, let proposal, proposal.container == .strip,
|
||||
DragLocality.isSameBoard(proposal.boardRoot, root)
|
||||
else { return nil }
|
||||
return proposal.index
|
||||
@@ -394,7 +473,20 @@ final class DragSession {
|
||||
/// The proposal's index when it names `laneID` on this board, else `nil` — the masonry's shadow
|
||||
/// run position, in the lane's logical card order.
|
||||
func laneProposal(onBoardRooted root: URL, laneID: ItemID) -> Int? {
|
||||
guard kind == .cards, let proposal, proposal.laneID == laneID,
|
||||
guard kind == .cards, let proposal, proposal.container == .lane(laneID),
|
||||
DragLocality.isSameBoard(proposal.boardRoot, root)
|
||||
else { return nil }
|
||||
return proposal.index
|
||||
}
|
||||
|
||||
/// The proposal's index when it names this board's **trash column**, else `nil` — the delete
|
||||
/// gesture's shadow row (04-interactions.md ▸ The trash).
|
||||
///
|
||||
/// Always `TrashDrop.landingIndex`, and read through this accessor anyway so the column asks the
|
||||
/// same "is it me?" question every other container asks, and gets the position from the same
|
||||
/// place.
|
||||
func trashProposal(onBoardRooted root: URL) -> Int? {
|
||||
guard kind == .cards, let proposal, proposal.container == .trash,
|
||||
DragLocality.isSameBoard(proposal.boardRoot, root)
|
||||
else { return nil }
|
||||
return proposal.index
|
||||
@@ -419,6 +511,19 @@ final class DragSession {
|
||||
return DropLanding(index: index, run: landingRun)
|
||||
}
|
||||
|
||||
/// The **trash column's** twin: the shadow rows the delete gesture opens at the top, and — once
|
||||
/// the release has settled — the tombstoned rows themselves, drawn there from the instant of
|
||||
/// release until the echo reload brings the real ones (`TrashLaneView`).
|
||||
///
|
||||
/// The settle is not decoration here, it is the whole of the gesture being legible: at release
|
||||
/// the dragged cards are already lifted out of their lanes (`hiddenMembers` — a delete removes
|
||||
/// its originals exactly as a move does), so with nothing drawn in the trash they would simply
|
||||
/// wink out of existence for a round trip.
|
||||
func trashLanding(onBoardRooted root: URL) -> DropLanding? {
|
||||
guard let index = trashProposal(onBoardRooted: root) else { return nil }
|
||||
return DropLanding(index: index, run: landingRun)
|
||||
}
|
||||
|
||||
/// The phase, as the two accessors above read it.
|
||||
///
|
||||
/// `isLocal` is what keeps a **colliding cross-board arrival** from being drawn twice: only a
|
||||
|
||||
Reference in New Issue
Block a user