diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 07041ef..0d1ca14 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -1918,11 +1918,8 @@ public final class BoardStore { // walks the filtered lane rather than selecting a card the query has hidden. let successor = SelectionGrammar.successor(afterDeleting: ids, in: snapshot, filter: searchFilter) - try? performWrite { () throws(BoardWriteError) -> Void in - for folder in folders { - try BoardWriter.deleteItem(at: folder) - } - } + tombstone(folders) + if let successor { select([successor], liveness: .live, anchor: successor, head: successor) } else { @@ -1930,6 +1927,51 @@ public final class BoardStore { } } + /// **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". + /// + /// *Exactly* the ⌫ tombstone is a claim about the disk, and `tombstone(_:)` is what makes it + /// structural rather than a matter of two call sites staying in step: one write op, one bracket, + /// one set of stamps, so a card deleted by drop and a card deleted by keystroke are + /// byte-indistinguishable afterwards (`TrashDropWriteTests`). + /// + /// ### The one thing it does not share is the successor + /// + /// ⌫ moves the selection to the deleted item's successor sibling because *the selection* lost its + /// cards and "repeated ⌫ walks down a lane" — the rule exists to keep a keyboard gesture + /// repeatable. A drag has no such continuation, and its run is **not necessarily the selection at + /// all**: dragging a card outside the selection drags that card alone and leaves the selection + /// exactly where it was (`LaneView.startCardDrag`), so picking a successor for it would re-point a + /// selection that never lost anything. + /// + /// So this writes and says nothing about the selection, and the ordinary reload does the rest: a + /// live-side set ejects members that flip to tombstoned, as the vanish it is (02-architecture.md's + /// reload-survival rule). Drag the selection itself onto the trash and the selection empties; + /// drag something else and it is untouched. Neither case needs surgery here. + /// + /// Cards only, by the gesture's own gate (`TrashDrop.accepts`) — but nothing here depends on + /// that: the paths resolve on the live side exactly as `delete(_:)`'s do. + public func deleteByDrag(cardIDs: [ItemID]) { + let folders = TrashModel.paths(of: Set(cardIDs), on: .live, in: snapshot) + .map { $0.folder(under: rootURL) } + guard !folders.isEmpty else { return } + tombstone(folders) + } + + /// The tombstone write itself — **one `performWrite` bracket, whatever the set's size and + /// whichever gesture asked** (DRAG-REORDER.md § The drop commits; the style batch's rule). + /// + /// Spelled once so ⌫ and drop-on-trash cannot drift apart on disk; everything that differs + /// between them is about the *selection*, and lives in the callers. + private func tombstone(_ folders: [URL]) { + try? performWrite { () throws(BoardWriteError) -> Void in + for folder in folders { + try BoardWriter.deleteItem(at: folder) + } + } + } + /// Put Back: removes `deleted:` from every tombstoned item in `ids`, in one bracket /// (03-board-ui.md § Trash). /// diff --git a/Kanban/UI/Board/BoardDrops.swift b/Kanban/UI/Board/BoardDrops.swift index 4c634da..b24a81a 100644 --- a/Kanban/UI/Board/BoardDrops.swift +++ b/Kanban/UI/Board/BoardDrops.swift @@ -159,7 +159,7 @@ struct BoardDropContext { ) guard let slot else { return } // a dead region: hold the current proposal let index = min(max(0, slot), restingUnits.count) - session.propose(DropTarget(boardRoot: store.rootURL, laneID: nil, index: index)) + session.propose(DropTarget(boardRoot: store.rootURL, container: .strip, index: index)) } /// Where a **card** session would land in `laneID`'s masonry. @@ -195,7 +195,7 @@ struct BoardDropContext { current: session.laneProposal(onBoardRooted: store.rootURL, laneID: laneID) ) guard let slot else { return } // a dead region: hold - session.propose(DropTarget(boardRoot: store.rootURL, laneID: laneID, index: slot)) + session.propose(DropTarget(boardRoot: store.rootURL, container: .lane(laneID), index: slot)) } /// The strip's fall-through for card sessions: which lane is under the cursor, analytically. @@ -227,6 +227,49 @@ struct BoardDropContext { } } + // MARK: Retargeting — the trash column + + /// Whether the trash column would take the session in flight right now — `TrashDrop.accepts` + /// with this board's state read in (04-interactions.md ▸ The trash, settled 2026-07-28). + /// + /// A method rather than a property because it is not free of consequence: the operation is + /// re-resolved against the modifiers *at this instant*, which is also what keeps the badge honest + /// while the cursor sits over the column (`dropProposal`). + func acceptsTrashDrop() -> Bool { + guard let sourceRoot = session.sourceRoot else { return false } + return TrashDrop.accepts( + kind: session.kind, + side: session.side, + isWithinBoard: DragLocality.isSameBoard(sourceRoot, store.rootURL), + operation: session.resolveOperation(destinationRoot: store.rootURL), + isTrashShown: store.transient.isTrashVisible, + acceptsMutations: store.acceptsBoardMutations + ) + } + + /// Where a session over the **trash column** would land: the topmost row, or nowhere. + /// + /// **A refusal falls through to the strip's own answer rather than withdrawing the proposal**, + /// which is precisely what this column did before it had a drop target of its own: a cursor over + /// it resolves to no lane, so `retargetCardsFromStrip` holds whatever the shadows already show + /// and `retargetLanes` clamps the terminal slot in front of the quasi-lane. That is the + /// hysteresis contract (DRAG-REORDER.md § Hysteresis) and it is also the honest reading of "the + /// trash proposes nothing for you": the column declines to be a target, it does not cancel the + /// drag the user is still holding. So a lane drag reorders across the column exactly as it always + /// did, and an ⌥-copy released over it still lands where its shadows are. + func retargetTrash() { + revalidateProposal() + guard acceptsTrashDrop() else { + retargetFromStrip() + return + } + session.propose(DropTarget( + boardRoot: store.rootURL, + container: .trash, + index: TrashDrop.landingIndex + )) + } + // MARK: Retargeting — external Finder file sessions /// Whether `info` is an **external Finder file** session rather than one of ours. @@ -457,6 +500,11 @@ struct BoardDropContext { /// The commit is the **destination** store's, one `performWrite` bracket per gesture whatever the /// set's size (DRAG-REORDER.md § The drop commits). /// + /// **One of the containers is not a destination but a verb.** A proposal naming the trash commits + /// a tombstone — the same write ⌫ performs, through the same `BoardWriter.deleteItem` in the same + /// bracket (`BoardStore.deleteByDrag`), so a card deleted by drop is indistinguishable on disk + /// from one deleted by keystroke (04-interactions.md ▸ The trash, settled 2026-07-28). + /// /// The write is the first half; the second is the **settle** (`DragSession.commit`). The write is /// still in flight when this returns, so the session flips from proposing to committed and the /// slot the shadows were holding starts drawing the dropped cards themselves — "at release the @@ -487,6 +535,15 @@ struct BoardDropContext { switch kind { case .lanes: + // **A lane drag never targets the trash**, and never a masonry either — a lane session + // proposes only lane slots (04-interactions.md ▸ The trash). True by construction, since + // `retargetLanes` is the only thing that proposes for one and the quasi-lane is absent + // from its slot list; written down because a commit that trusted the container implicitly + // would be the one place the invariant could break silently. + guard target.container == .strip else { + cancelDrop() + return false + } if within { store.moveLanes(Set(ids), toIndex: target.index) } else { @@ -494,6 +551,28 @@ struct BoardDropContext { } case .cards: + if target.isTrash { + // **The pointer's delete gesture** (04-interactions.md ▸ The trash, settled + // 2026-07-28): "release tombstones the dragged card(s), exactly the ⌫ tombstone". + // + // The gate is re-asked here rather than trusted from the hover, because the one input + // that can change between them arrives through no callback at all: ⌥ pressed after + // the proposal stood would otherwise tombstone an original the copy grammar had just + // promised to leave alone. A refusal cancels — items return, nothing is written. + guard TrashDrop.accepts( + kind: kind, + side: session.side, + isWithinBoard: within, + operation: operation, + isTrashShown: store.transient.isTrashVisible, + acceptsMutations: store.acceptsBoardMutations + ) else { + cancelDrop() + return false + } + store.deleteByDrag(cardIDs: ids) + break + } guard let laneID = target.laneID else { cancelDrop() return false @@ -773,9 +852,12 @@ struct LaneDropDelegate: DropDelegate { } } -/// The strip's drop target — the backdrop, the gaps, the outer margin, and the trash column's -/// footprint, which is never a landing spot of its own (04-interactions.md ▸ The trash) and so -/// simply falls through to here. +/// The strip's drop target — the backdrop, the gaps and the outer margin. +/// +/// The trash column used to fall through to here too, being no landing spot of its own; it has its +/// own target now that a live card drag can *delete* into it (`TrashDropDelegate`), and that target +/// hands every session it declines straight back to this logic, so the behaviour over the column is +/// unchanged for everything but the one gesture that is new. /// /// Lane sessions retarget against the strip's analytic zones; card sessions retarget through the /// same shared function the lane delegates use, resolving the lane under the cursor analytically — @@ -818,6 +900,65 @@ struct StripDropDelegate: DropDelegate { } } +/// The **trash column's** drop target — the pointer's delete gesture (04-interactions.md ▸ The +/// trash, settled 2026-07-28: "dropping a live card on the shown trash deletes it"). +/// +/// It exists only while the column does. Hidden, the trash renders nothing, so there is no region +/// here to enter and "the trash stays undroppable-into while hidden, like every gesture" needs no +/// code — `TrashDrop.accepts` restates it anyway, because an invariant that is only true by +/// construction is worth being able to point at. +/// +/// Like every other delegate it accepts **all three** session types, because single-target dispatch +/// gives the deepest region the session whether it wants it or not (see the note above), and it +/// routes them three ways: +/// +/// - **card sessions** through `retargetTrash`, which proposes the topmost row for the ones the trash +/// takes and falls through to the strip's own answer for the rest; +/// - **lane sessions** the same way, and `TrashDrop.accepts` refuses them there, so what actually +/// runs is the strip's `retargetLanes` — lane reordering keeps working across the column exactly as +/// it did when the column was a hole in the strip's target; +/// - **Finder file sessions** by clearing the highlight outright: "Finder file drops (attachment +/// import) on tombstoned cards are inert" (▸ The trash), and the column has nothing else to offer +/// them — no lane, no card, nothing to attach to. +struct TrashDropDelegate: DropDelegate { + + let context: BoardDropContext + + func validateDrop(info: DropInfo) -> Bool { + if context.isFileSession(info) { return context.acceptsFileDrop(info) } + return context.session.isActive && info.hasItemsConforming(to: boardDragTypes) + } + + func dropEntered(info: DropInfo) { retarget(info) } + + func dropUpdated(info: DropInfo) -> DropProposal? { + retarget(info) + return context.isFileSession(info) ? context.fileDropProposal() : context.dropProposal() + } + + /// See `LaneDropDelegate.dropExited` — the file mode's clear, and nothing for our own sessions, + /// whose proposals are meant to hold across ambiguous territory. + func dropExited(info: DropInfo) { + guard context.isFileSession(info) else { return } + context.session.proposeFile(nil) + } + + /// A release on the column commits whatever stands — the tombstone when the trash is the + /// proposal, and otherwise the proposal the column declined to displace, which is the same + /// "the drop lands where the shadows show" promise as anywhere else. + func performDrop(info: DropInfo) -> Bool { + context.isFileSession(info) ? context.commitFileDrop(info) : context.commitDrop() + } + + private func retarget(_ info: DropInfo) { + if context.isFileSession(info) { + context.session.proposeFile(nil) + return + } + context.retargetTrash() + } +} + /// The window-level fallback, behind every specific target: a release over any in-window region they /// do not cover (the banner strip, the window's edges) commits the current proposal rather than /// leaking the session into a cancel-snapback. It retargets nothing — the drop lands where the diff --git a/Kanban/UI/Board/DragSession.swift b/Kanban/UI/Board/DragSession.swift index 9c5a41c..f37cde2 100644 --- a/Kanban/UI/Board/DragSession.swift +++ b/Kanban/UI/Board/DragSession.swift @@ -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 { 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 diff --git a/Kanban/UI/Board/TrashLaneView.swift b/Kanban/UI/Board/TrashLaneView.swift index b102e08..be90ed5 100644 --- a/Kanban/UI/Board/TrashLaneView.swift +++ b/Kanban/UI/Board/TrashLaneView.swift @@ -19,13 +19,32 @@ import SwiftUI /// - it is **not draggable and not reorderable** — the header carries no gesture, and it is absent /// from the drop proposal's slot list by construction, since `BoardView` builds that from the /// snapshot's live lanes; -/// - it is **never a drop target** — "no move or paste ever targets the trash" (04-interactions.md ▸ -/// The trash), so nothing here declares an `onDrop` at all and a session over the column falls -/// through to the strip's own target, where a cursor over no lane simply holds the proposal. That -/// covers **Finder file drops** too, which "on tombstoned cards are inert" (▸ The trash): a file -/// session over this column resolves to no lane, so no row highlights and a release refuses; +/// - it is **no destination for a move** — "no move or paste ever targets the trash" +/// (04-interactions.md ▸ The trash). It is a destination for exactly one thing, below; /// - it has **no new-card button**: nothing is created in the trash. /// +/// ### The one drop it takes: the delete gesture +/// +/// **"Dropping a live card on the shown trash deletes it"** (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". So the column does declare an `onDrop` +/// (`TrashDropDelegate`), and it is the narrowest one on the board: a **live** card drag from **this** +/// board, unmodified. Lanes are not deliverable this way, a foreign board's card is not (that would +/// be a transfer-and-delete compound), ⌥ is not (copying into the trash is not a thing), and hidden +/// the column is not rendered at all, so it has no region to enter. Every one of those refusals hands +/// the session back to the strip's own logic, which is exactly what happened here before this target +/// existed — so nothing about the column's behaviour changed except the gesture that is new +/// (`TrashDrop`). +/// +/// It diverges from every other drop on the board in one way, and the sort is what makes the +/// divergence honest: **the shadow always takes the topmost row**, because the trash orders by +/// `deleted` newest-first and a fresh tombstone genuinely lands on top. The drop still lands exactly +/// where the shadow shows. +/// +/// **Finder file drops stay inert** — "attachment import on tombstoned cards is inert" (▸ The trash) +/// — and now say so directly: the delegate clears the file highlight over the column rather than +/// relying on the strip resolving to no lane. +/// /// ### No editing in the trash /// /// "Tombstoned cards don't open — double-click does nothing beyond selection; Put Back or drag out @@ -71,6 +90,12 @@ struct TrashLaneView: View { private let rowSpacing: CGFloat = 6 + /// The height a shadow row holds open. A trash row's height is content-driven (one or two title + /// lines, plus a lane entry's count line) and the cards being proposed have no row yet to be + /// measured, so the shadow is drawn at the nominal single-line plate — `LaneDropRegistry`'s own + /// answer to the same question, in this column's smaller idiom. + private let nominalRowHeight: CGFloat = 32 + var body: some View { VStack(alignment: .leading, spacing: 0) { header @@ -80,6 +105,12 @@ struct TrashLaneView: View { RoundedRectangle(cornerRadius: cornerRadius) .fill(.quaternary.opacity(0.35)) ) + // **The delete gesture's drop target**, over the whole column — the header included, since + // the ruling is "dropping a live card on the shown trash", not on one of its rows. It + // declares every type the board's other targets do, because single-target dispatch hands the + // deepest region whatever session is in flight and a narrower target would strand the rest + // (`TrashDropDelegate`). + .onDrop(of: boardDropTypes, delegate: TrashDropDelegate(context: drops)) } /// The rows the column shows. @@ -94,6 +125,65 @@ struct TrashLaneView: View { return TrashModel.entries(of: store.snapshot).filter { filter.matches($0) } } + // MARK: - The delete gesture's landing + + /// What the column draws at the drop proposal — the shadow run while the drag is in flight, the + /// tombstoned rows themselves once the release has settled (`DragSession.trashLanding`). `nil` + /// when no proposal names the trash, which is every other moment of the app's life. + private var landing: DropLanding? { + drops.session.trashLanding(onBoardRooted: store.rootURL) + } + + /// The rows the `VStack` lays out: the entries, with the delete gesture's run opened at the top. + /// + /// **The settled run displaces the entries it is standing in for.** For the one render pass where + /// the echo has landed but the hold has not yet been retired (`BoardView` hands off on the *next* + /// snapshot, an `onChange` later), the arriving card is in both collections at once — so the real + /// entry steps aside and the overlay's row keeps the slot. That is `LaneView`'s hidden-members + /// rule at the other end of the same gesture: never draw the arrangement twice. + /// + /// The two then swap **inside one element**, because both are keyed by the card's own identity: a + /// tombstone is not a remint — the card keeps its GUID, its folder and its bytes — so this + /// landing, alone among the board's, can always promise the key. No insert, no remove, no + /// transition to fire; "the handoff must read as one arrival" (02-architecture.md ▸ + /// TransientBoardState ▸ overlays). + private var slots: [TrashSlot] { + guard let landing else { return entries.map(TrashSlot.entry) } + + let arriving: [ItemID] + let run: [TrashSlot] + switch landing.run { + case .shadows: + arriving = [] + run = (0.. some View { + let card = store.snapshot.lanes.lazy + .flatMap(\.cards) + .first { $0.id == item.id } + return TrashRowPlate( + symbol: card.map { ItemSymbol.name($0.icon, fallback: ItemSymbol.card) } ?? ItemSymbol.card, + title: card?.title.value ?? item.title, + subtitle: nil, + isSelected: false + ) + } + // MARK: - Header /// Dimmed and hatched, with the trash symbol, the stable "Trash" title and a count badge @@ -158,7 +248,7 @@ struct TrashLaneView: View { scrollableRows .onChange(of: store.transient.selectionHead) { _, head in guard let head, entries.contains(where: { $0.id == head }) else { return } - proxy.scrollTo(head) + proxy.scrollTo(TrashSlot.identity(of: head)) } } } @@ -171,23 +261,43 @@ struct TrashLaneView: View { // has scrolled to, so an unbuilt row is invisible to both. The constraint is affordable // because a trash is small: it holds one board's tombstones, and Empty Trash… exists. VStack(alignment: .leading, spacing: rowSpacing) { - ForEach(entries) { entry in - TrashEntryRow( - store: store, - entry: entry, - confirmations: confirmations, - drops: drops, - registry: marquee.registry - ) + ForEach(slots) { slot in + Group { + switch slot { + case let .entry(entry): + TrashEntryRow( + store: store, + entry: entry, + confirmations: confirmations, + drops: drops, + registry: marquee.registry + ) + case .shadow: + // The delete gesture's shadow, holding the topmost row open + // (04-interactions.md ▸ The trash). + DragShadow(cornerRadius: 6) + .frame(maxWidth: .infinity) + .frame(height: nominalRowHeight) + case let .dropped(item): + // The same row one instant later: the release has settled and the + // tombstone is drawn where its shadow was, rather than the cards winking + // out for a round trip (`DragSession.trashLanding`). + droppedRow(item) + } + } // A row is a tombstoned item, so it arrives and leaves in the card's dialect — // a delete files one in, a Put Back or a purge takes one out, and both halves of // that pair should read alike from either side of the strip. The transaction is // the reload's, like the lanes' (`Motion.reloadAnimates`). .transition(Motion.cardTransition(reduced: reduceMotion)) // The scroll target — `LaneView`'s rule, and outermost for its reason. - .id(entry.id) + .id(slot.id) } } + // The reflow that opens the topmost row for the shadow, keyed on **the drop proposal and + // nothing else** (03-board-ui.md § Motion's narrow keys) — the trash column's own copy of + // the rule `BoardView` applies to the strip and `LaneView` to its masonry. + .animation(Motion.dragReflow(reduced: reduceMotion), value: landing?.index) // `maxHeight: .infinity` here, not just `maxWidth`, is what makes the gesture surface // below reach the column's full height rather than stopping where the last row ends — // the same fix `LaneView.scrollableCards` applies to its masonry, and for the identical @@ -208,6 +318,98 @@ struct TrashLaneView: View { } } +// MARK: - The plate + +/// The trash row's *appearance*, with none of anybody's behaviour — the compact, dimmed plate a +/// tombstone wears. +/// +/// **Three views draw it and none of them may drift**: the row itself, its own drag replica (a drag +/// image is a snapshot, and one built out of the live plate would re-register the row's frame from +/// inside the preview's geometry and then deregister it when the image went away — quietly stealing +/// the row from the rubber band and the arrow keys), and the **settled delete's** stand-in row, which +/// draws a card that has been tombstoned on disk but is not in the snapshot yet +/// (`TrashLaneView.droppedRow`). The last one is why this is a view rather than a computed property: +/// it renders for an item that has no `TrashEntry` at all. +private struct TrashRowPlate: View { + + let symbol: String + + /// The title as written, or `nil` for an untitled item — "Untitled" is a rendering, never a value + /// (03-board-ui.md § Card face). + let title: String? + + /// A lane entry's returning-card count, and nothing else takes a second line. + let subtitle: String? + + var isSelected: Bool = false + + private let cornerRadius: CGFloat = 6 + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: symbol) + .foregroundStyle(.secondary) + .imageScale(.small) + VStack(alignment: .leading, spacing: 2) { + Text(title ?? "Untitled") + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(2) + if let subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(.tertiary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary.opacity(0.6))) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius) + .strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5) + ) + } +} + +// MARK: - What the column lays out + +/// One row of the trash column — an entry, or one slot of the delete gesture's run. +/// +/// `LaneSlot`'s smaller sibling, and keyed on the same principle: a slot's id decides whether the +/// echo reload reads as a *swap* or as a removal and an insertion. +private enum TrashSlot: Identifiable { + + /// A tombstone the snapshot already holds. + case entry(TrashEntry) + + /// One of the drag's N shadows, holding the topmost rows open (04-interactions.md ▸ The trash). + case shadow(index: Int) + + /// One of the **dropped** cards, drawn as the row it is about to be, from the instant of release + /// until the echo reload brings the real entry. + case dropped(DroppedItem) + + var id: String { + switch self { + case let .entry(entry): Self.identity(of: entry.id) + // Constant per position in the run, so a run that grows or shrinks animates as slots rather + // than blinking (`LaneSlot`'s rule). + case let .shadow(index): "shadow:\(index)" + // **The one landing on this board that can always promise a key.** A tombstone keeps the + // card's GUID — a delete remints nothing — so the settled row wears the arriving entry's own + // identity and the echo swaps content inside one element, where the masonry's `.dropped` has + // to key positionally whenever a copy or an import boundary might remint. + case let .dropped(item): Self.identity(of: item.id) + } + } + + /// An entry slot's id, spelled once so `scrollTo` and the slot cannot disagree about what the + /// scroll reader is looking for (`LaneSlot.identity(of:)`). + static func identity(of item: ItemID) -> String { "entry:\(item.rawValue)" } +} + // MARK: - The hatch /// Diagonal hatching for the trash header — the "dimmed/hatched" treatment 03-board-ui.md asks for, @@ -238,8 +440,8 @@ private struct DiagonalHatch: Shape { /// /// **Face-like but not a card face.** It shares the plate, the symbol and the title, and it /// deliberately shares none of the face's *editing* affordances: no rename editor, no Open, no -/// Style…, no attachment carousel. That is 03-board-ui.md's no-editing-in-the-trash rule expressed -/// as an absence rather than as a pile of `disabled` modifiers. +/// Style…. That is 03-board-ui.md's no-editing-in-the-trash rule expressed as an absence rather +/// than as a pile of `disabled` modifiers. private struct TrashEntryRow: View { let store: BoardStore @@ -251,8 +453,6 @@ private struct TrashEntryRow: View { /// side (`View.marqueeTarget`). let registry: MarqueeTargetRegistry - private let cornerRadius: CGFloat = 6 - /// **Lane entries are not draggable** (03-board-ui.md § Trash: "a lane entry is not draggable — /// its entry is a compact row, not the lane; its move-out is Put Back"), so the drag half is /// simply *absent* for them rather than refused — no session, no image, no snap-back. A click @@ -283,39 +483,15 @@ private struct TrashEntryRow: View { .contextMenu { menu } } - /// The plate's *appearance*, with none of its behaviour — no gesture, no context menu, and - /// crucially no marquee registration. - /// - /// The split exists for the drag replica, which renders this and nothing else: a drag image is a - /// snapshot, and one built out of the live plate would re-register this row's frame from inside - /// the preview's own geometry and then *deregister* it when the image went away, quietly - /// stealing the row from the rubber band and the arrow keys. + /// This entry as the shared plate draws it — appearance only, no gesture, no context menu and + /// crucially no marquee registration, which is what makes it safe for the drag replica to render + /// (see `TrashRowPlate`). private var rowFace: some View { - HStack(alignment: .firstTextBaseline, spacing: 6) { - Image(systemName: ItemSymbol.name(entry.icon, fallback: symbolFallback)) - .foregroundStyle(.secondary) - .imageScale(.small) - VStack(alignment: .leading, spacing: 2) { - Text(entry.title ?? "Untitled") - .font(.callout) - .foregroundStyle(.secondary) - .lineLimit(2) - if case let .lane(_, returning) = entry { - // "N cards" — what Put Back brings back with the lane, not how many folders sit - // inside it (`TrashModel.entries`' returning-count rule). - Text("\(returning) card\(returning == 1 ? "" : "s")") - .font(.caption) - .foregroundStyle(.tertiary) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(.horizontal, 8) - .padding(.vertical, 6) - .background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary.opacity(0.6))) - .overlay( - RoundedRectangle(cornerRadius: cornerRadius) - .strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5) + TrashRowPlate( + symbol: ItemSymbol.name(entry.icon, fallback: symbolFallback), + title: entry.title, + subtitle: laneSubtitle, + isSelected: isSelected ) } @@ -323,6 +499,13 @@ private struct TrashEntryRow: View { entry.isLaneEntry ? ItemSymbol.lane : ItemSymbol.card } + /// "N cards" — what Put Back brings back with a tombstoned lane, not how many folders sit inside + /// it (`TrashModel.entries`' returning-count rule). Card entries have no second line. + private var laneSubtitle: String? { + guard case let .lane(_, returning) = entry else { return nil } + return "\(returning) card\(returning == 1 ? "" : "s")" + } + // MARK: - Selection private var isSelected: Bool { diff --git a/KanbanTests/DragSessionTests.swift b/KanbanTests/DragSessionTests.swift index 749dad0..fb7384d 100644 --- a/KanbanTests/DragSessionTests.swift +++ b/KanbanTests/DragSessionTests.swift @@ -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") diff --git a/KanbanTests/TrashWriteTests.swift b/KanbanTests/TrashWriteTests.swift index 7b6bbc2..c31d308 100644 --- a/KanbanTests/TrashWriteTests.swift +++ b/KanbanTests/TrashWriteTests.swift @@ -188,6 +188,118 @@ struct TrashDeleteTests { } } +// MARK: - Delete by drop + +/// **Drop-on-trash deletes** (04-interactions.md ▸ The trash, settled 2026-07-28): "release +/// tombstones the dragged card(s), exactly the ⌫ tombstone". +/// +/// *Exactly* is the claim under test, and it is a claim about the disk — so these run the two +/// gestures over two identical fixtures and compare the bytes. Where the drop *may* land and what it +/// draws on the way are `TrashDropTests`' and `DropSettleTests`'; here it has already landed. +@MainActor +@Suite("BoardStore ▸ delete by drop") +struct TrashDropWriteTests { + + @Test("A drop-delete is byte-for-byte the ⌫ tombstone") + func indistinguishableFromTheKeystroke() throws { + let byKey = try makeBoard() + defer { byKey.tearDown() } + let byDrop = try makeBoard() + defer { byDrop.tearDown() } + + try BoardStore(rootURL: byKey.root).delete([card1]) + try BoardStore(rootURL: byDrop.root).deleteByDrag(cardIDs: [card1]) + + let keyed = try byKey.indexText("\(Ident.lane1)/\(Ident.card1)") + let dropped = try byDrop.indexText("\(Ident.lane1)/\(Ident.card1)") + // Everything but the two stamps that are clocks rather than content, which differ between any + // two writes at all — including two ⌫ presses. + #expect(untouchedLines(dropped) == untouchedLines(keyed)) + #expect(try FrontmatterDocument.parse(dropped).deleted.value != nil) + #expect(!dropped.contains("modified-by"), "an app-mediated write clears an external writer's attribution") + } + + /// A multi-selection drag carries its whole run across lanes, and the tombstone is the card's own + /// `index.md` and nothing else — the parents are not rewritten to record a child's departure, + /// because nothing departed. + @Test("A cross-lane run lands whole and touches nothing it did not carry") + func theWholeRunLands() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let lane = try stat(fixture, Ident.lane1) + + store.deleteByDrag(cardIDs: [card1, card3]) + + #expect(try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\(Ident.card1)")) + .deleted.value != nil) + #expect(try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane2)/\(Ident.card3)")) + .deleted.value != nil) + let laneAfter = try stat(fixture, Ident.lane1) + #expect(laneAfter.data == lane.data) + #expect(laneAfter.modified == lane.modified) + #expect(store.banners.oneShots.isEmpty) + } + + /// The one thing the drop deliberately does *not* share with ⌫. The keystroke picks a successor + /// because the selection lost its cards and "repeated ⌫ walks down a lane"; a drag's run is not + /// necessarily the selection at all, so re-pointing one that lost nothing would be a bug. The + /// reload's resolve rule ejects tombstoned members from a live-side set on its own. + @Test("A drop-delete never touches the selection, where ⌫ moves it to the successor") + func theSelectionIsLeftAlone() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + // Something else entirely is selected — dragging a card outside the selection drags it alone + // and leaves the selection standing (`LaneView.startCardDrag`). + store.select([card3], liveness: .live, anchor: card3, head: card3) + + store.deleteByDrag(cardIDs: [card1]) + + #expect(store.selection.ids == [card3]) + #expect(store.selection.liveness == .live) + + // The keystroke's contrasting half, over an identical board: ⌫ re-points the selection + // whatever was in it, because it is the gesture that promises to walk down a lane. + let keyed = try makeBoard() + defer { keyed.tearDown() } + let keyedStore = try BoardStore(rootURL: keyed.root) + keyedStore.select([card3], liveness: .live, anchor: card3, head: card3) + + keyedStore.delete([card1]) + + #expect(keyedStore.selection.ids != [card3]) + } + + @Test("Already-tombstoned ids are skipped, and an empty run writes nothing") + func liveOnly() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let trashed = try stat(fixture, "\(Ident.lane1)/\(Ident.card2)") + + store.deleteByDrag(cardIDs: [card2]) + store.deleteByDrag(cardIDs: []) + + #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card2)").modified == trashed.modified) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("A read-only board refuses the drop without a second banner") + func readOnlyRefusesQuietly() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + store.enterVanishedRootLock() + let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") + + store.deleteByDrag(cardIDs: [card1]) + + #expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before) + #expect(store.banners.oneShots.isEmpty, "the lock row is already standing") + } +} + // MARK: - Put Back @MainActor