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:
@@ -145,6 +145,93 @@ struct DragLocalityTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dropping on the trash
|
||||
|
||||
/// **The delete gesture's gate** (04-interactions.md ▸ The trash, settled 2026-07-28: "dropping a
|
||||
/// live card on the shown trash deletes it").
|
||||
///
|
||||
/// One pure function decides it, and it is asked twice — once at hover for the shadow and once at
|
||||
/// release for the write — so every clause below is a claim about both.
|
||||
@Suite("TrashDrop")
|
||||
struct TrashDropTests {
|
||||
|
||||
/// The accepted session, with one clause at a time knocked out by the cases.
|
||||
private func accepts(
|
||||
kind: DragKind? = .cards,
|
||||
side: Liveness = .live,
|
||||
isWithinBoard: Bool = true,
|
||||
operation: TransferOperation = .move,
|
||||
isTrashShown: Bool = true,
|
||||
acceptsMutations: Bool = true
|
||||
) -> Bool {
|
||||
TrashDrop.accepts(
|
||||
kind: kind,
|
||||
side: side,
|
||||
isWithinBoard: isWithinBoard,
|
||||
operation: operation,
|
||||
isTrashShown: isTrashShown,
|
||||
acceptsMutations: acceptsMutations
|
||||
)
|
||||
}
|
||||
|
||||
/// "The shadow always takes the topmost position — which the sort makes honest, not arbitrary:
|
||||
/// the trash orders by `deleted` newest-first, so a fresh tombstone genuinely lands on top."
|
||||
@Test("The landing is the topmost row, always")
|
||||
func theTopmostRow() {
|
||||
#expect(TrashDrop.landingIndex == 0)
|
||||
}
|
||||
|
||||
@Test("A live, same-board, unmodified card drag is the one session the trash takes")
|
||||
func theOneItTakes() {
|
||||
#expect(accepts())
|
||||
// ⌘ forces move, which is already the default here, so it changes nothing.
|
||||
#expect(accepts(operation: .move))
|
||||
}
|
||||
|
||||
/// "Lanes are not deliverable this way (a lane drag proposes only lane slots)."
|
||||
@Test("A lane drag never proposes into the trash")
|
||||
func lanesAreNotDeliverable() {
|
||||
#expect(!accepts(kind: .lanes))
|
||||
// And no session at all is no proposal either — the column is inert between drags.
|
||||
#expect(!accepts(kind: nil))
|
||||
}
|
||||
|
||||
/// A trash row's drag is restore/copy-out grammar; dropped back where it came from it writes
|
||||
/// nothing, so it never proposes.
|
||||
@Test("A trash row dropped back on the trash is refused")
|
||||
func theTrashedSideIsRefused() {
|
||||
#expect(!accepts(side: .trashed))
|
||||
#expect(!accepts(side: .trashed, isWithinBoard: false))
|
||||
}
|
||||
|
||||
/// "No move or paste ever targets the trash": a foreign card delivered into this board's trash
|
||||
/// would be a transfer-and-delete compound, which the design names nowhere.
|
||||
@Test("A foreign board's card is refused")
|
||||
func crossBoardIsRefused() {
|
||||
#expect(!accepts(isWithinBoard: false))
|
||||
// Not even with ⌘, which forces the move a cross-board drag would otherwise only copy.
|
||||
#expect(!accepts(isWithinBoard: false, operation: .move))
|
||||
}
|
||||
|
||||
/// Copying into the trash is not a thing — and the alternative would be tombstoning an original
|
||||
/// the copy grammar had just promised to leave exactly where it was.
|
||||
@Test("⌥ is refused rather than reinterpreted")
|
||||
func optionCopyIsRefused() {
|
||||
#expect(!accepts(operation: .copy))
|
||||
}
|
||||
|
||||
/// "The trash stays undroppable-into while hidden, like every gesture."
|
||||
@Test("Hidden, the trash is invisible to the gesture")
|
||||
func hiddenIsInert() {
|
||||
#expect(!accepts(isTrashShown: false))
|
||||
}
|
||||
|
||||
@Test("The mutating-gesture rule applies, like every other write the pointer can start")
|
||||
func theLockAndTheEditorRefuse() {
|
||||
#expect(!accepts(acceptsMutations: false))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The committed-overlay hold
|
||||
|
||||
@Suite("CommittedHold")
|
||||
@@ -244,7 +331,7 @@ struct DropSettleTests {
|
||||
) -> DragSession {
|
||||
let session = DragSession()
|
||||
pickUp(session, from: store, members: members)
|
||||
session.propose(DropTarget(boardRoot: store.rootURL, laneID: Self.lane1, index: index))
|
||||
session.propose(DropTarget(boardRoot: store.rootURL, container: .lane(Self.lane1), index: index))
|
||||
return session
|
||||
}
|
||||
|
||||
@@ -362,11 +449,88 @@ struct DropSettleTests {
|
||||
session.commit(into: store, survivors: [0], operation: .move)
|
||||
|
||||
session.propose(nil)
|
||||
session.propose(DropTarget(boardRoot: store.rootURL, laneID: Self.lane1, index: 0))
|
||||
session.propose(DropTarget(boardRoot: store.rootURL, container: .lane(Self.lane1), index: 0))
|
||||
|
||||
#expect(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1)?.index == 2)
|
||||
}
|
||||
|
||||
// MARK: The trash's landing
|
||||
|
||||
/// A session proposing into the trash column rather than into a lane — the delete gesture
|
||||
/// (04-interactions.md ▸ The trash).
|
||||
private func proposingIntoTheTrash(_ store: BoardStore, members: [(id: ItemID, title: String?)] = [(card1, "First")]) -> DragSession {
|
||||
let session = DragSession()
|
||||
pickUp(session, from: store, members: members)
|
||||
session.propose(DropTarget(
|
||||
boardRoot: store.rootURL,
|
||||
container: .trash,
|
||||
index: TrashDrop.landingIndex
|
||||
))
|
||||
return session
|
||||
}
|
||||
|
||||
@Test("The trash's shadow opens at the topmost row, and no lane draws one")
|
||||
func trashInFlightDrawsTheTopRow() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let session = proposingIntoTheTrash(store)
|
||||
|
||||
let landing = try #require(session.trashLanding(onBoardRooted: store.rootURL))
|
||||
#expect(landing.index == 0)
|
||||
#expect(landing.run == .shadows)
|
||||
// The proposal names one container and one only: the lane the cards came out of draws
|
||||
// nothing, and neither does the strip.
|
||||
#expect(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil)
|
||||
#expect(session.stripProposal(onBoardRooted: store.rootURL) == nil)
|
||||
// And they are still lifted out of the lane while the drag is in flight, as ever.
|
||||
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1])
|
||||
}
|
||||
|
||||
/// The settle, at the one landing whose cards would otherwise wink out of existence: the write
|
||||
/// takes them off the live side, so the trash has to draw them from the instant of release.
|
||||
@Test("A settled trash drop draws the tombstoned rows on top and keeps the originals lifted")
|
||||
func trashSettleDrawsTheRows() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let session = proposingIntoTheTrash(store, members: [(Self.card1, "First"), (Self.card2, "Second")])
|
||||
|
||||
// A delete is committed as the move it is — the write really did take the originals away.
|
||||
session.commit(into: store, survivors: [0, 1], operation: .move)
|
||||
|
||||
let landing = try #require(session.trashLanding(onBoardRooted: store.rootURL))
|
||||
#expect(landing.index == 0, "the slot does not move at the settle — only what it contains")
|
||||
let drop = try #require(landing.dropped)
|
||||
#expect(drop.items == [
|
||||
DroppedItem(id: Self.card1, title: "First"),
|
||||
DroppedItem(id: Self.card2, title: "Second")
|
||||
])
|
||||
// A tombstone remints nothing, so the settled rows may wear the cards' own identities and the
|
||||
// echo reload swaps content inside one element rather than removing and inserting.
|
||||
#expect(drop.keepsIdentity)
|
||||
#expect(drop.isLocal)
|
||||
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1, Self.card2])
|
||||
}
|
||||
|
||||
@Test("A lane session never draws a trash landing")
|
||||
func laneSessionsHaveNoTrashLanding() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let session = DragSession()
|
||||
session.beginLanes(
|
||||
[Self.lane1],
|
||||
folders: [store.rootURL.appendingPathComponent(Ident.lane1, isDirectory: true)],
|
||||
titles: ["Todo"],
|
||||
units: [1],
|
||||
source: store
|
||||
)
|
||||
session.propose(DropTarget(boardRoot: store.rootURL, container: .trash, index: 0))
|
||||
|
||||
#expect(session.trashLanding(onBoardRooted: store.rootURL) == nil)
|
||||
}
|
||||
|
||||
// MARK: The hand-off
|
||||
|
||||
@Test("The hand-off clears the hold and the overlay with it — the snapshot is the authority again")
|
||||
|
||||
Reference in New Issue
Block a user