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:
@@ -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..<drops.session.shadowCount).map(TrashSlot.shadow)
|
||||
case let .dropped(drop):
|
||||
arriving = drop.items.map(\.id)
|
||||
run = drop.items.map(TrashSlot.dropped)
|
||||
}
|
||||
|
||||
let displaced = Set(arriving)
|
||||
var result: [TrashSlot] = entries.filter { !displaced.contains($0.id) }.map(TrashSlot.entry)
|
||||
result.insert(contentsOf: run, at: min(max(0, landing.index), result.count))
|
||||
return result
|
||||
}
|
||||
|
||||
/// One dropped card, drawn as the trash row it is about to become.
|
||||
///
|
||||
/// The card is still **live in this board's snapshot** at this instant — the write is in flight —
|
||||
/// so its real symbol and title are right there to draw, and the payload's title is the fallback
|
||||
/// for the case that cannot happen here but costs one `??` to survive.
|
||||
private func droppedRow(_ item: DroppedItem) -> 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 {
|
||||
|
||||
Reference in New Issue
Block a user