import AppKit import Observation import SwiftUI // MARK: - TrashLaneView /// The trash quasi-lane: the trailing, visually distinct column tombstoned items live in /// (03-board-ui.md § Trash). /// /// ### A pure view, and a quasi-lane /// /// **Nothing here moves anything on disk.** Tombstoned items keep their `deleted:` key and stay /// exactly where they are; this column is a rendering of `TrashModel.entries(of:)` and nothing more. /// It is a *quasi*-lane because it wears a lane's shape while sharing none of a lane's machinery: /// /// - it spans a **fixed one width unit** — no `width` frontmatter, no stepper, no resize handle, and /// the edge drag never reaches it (`LaneLayoutMath.totalUnits(of:trashUnits:)` supplies the unit, /// so there is no `Lane` value for any of those to act on); /// - 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 **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 /// first." There is deliberately no double-tap recogniser, no rename editor, and no Style… row: the /// trash is for restoring or purging, not working. /// /// ### What is still a later card's /// /// **⌘C copy-out** is still owed. The **search filter** ("shown, it participates in the filter like /// any lane") arrived with m5 and is one line — see `entries`, which every other surface here reads /// through. The *pointer* grammar is here: a row's click runs the same `SelectionGrammar` the /// board does, and the column's empty space rubber-bands on the trashed side. So is the drop, with /// the target lane highlighted and the source row dimmed in place; the drag's replica is not. The /// **keyboard** reaches the column entirely through the frames the rows register — arrow walks in /// and out, ⇧-arrows inert at both the liveness and the kind boundary — so nothing in this file /// implements it beyond keeping every row drawn and registered (see `rows`). struct TrashLaneView: View { let store: BoardStore /// The window's purge-alert host, threaded down rather than read from the focus system: a /// context menu's content is built in its own host, where a `@FocusedValue` is not reliably the /// board window's, and the row's Delete Immediately must raise the *same* alert the menu bar's /// does. let confirmations: TrashConfirmations /// The board window's drop machinery — a row's drag is an ordinary card session on the /// **trashed** side (`DragSession`, 04-interactions.md ▸ The trash). let drops: BoardDropContext /// The strip's rubber band. The column's empty space is its third surface, on the **trashed** /// side — "a rubber-band stays on the side of the boundary it started on" (04-interactions.md ▸ /// The trash) — and every row registers its frame into the same registry. let marquee: MarqueeControl /// Reduce Motion, for the row transition below — 10-accessibility.md names the trash /// specifically ("and trash animations all get reduced variants"). @Environment(\.accessibilityReduceMotion) private var reduceMotion /// The lane plate's corner radius — matched to `LaneView`'s so the column reads as a sibling of /// the lanes rather than as a different kind of object. private let cornerRadius: CGFloat = 10 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 rows } .background( 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. /// /// **The shown trash "participates in the filter like any lane"** (03-board-ui.md § Trash), so /// the search predicate narrows this collection exactly as it narrows `LaneView.renderedCards` /// — card rows and lane rows alike, each by its own title and body (`SearchFilter`) — and the /// count badge follows for free, because it reads this same value. Hidden, the column renders /// nothing and registers nothing, so "hidden trash is invisible to search" needs no code at all. private var entries: [TrashEntry] { let filter = store.searchFilter 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 /// (03-board-ui.md § Trash ▸ Rendering). /// /// The hatching is what makes the column read as *not a lane* at a glance — the design asks for /// "visually distinct", and a lane's header is the surface this must not be mistaken for. It /// carries no gesture at all: no selection (the quasi-lane "is never selectable as a lane"), no /// reorder drag, no context menu. private var header: some View { HStack(alignment: .firstTextBaseline, spacing: 6) { Image(systemName: "trash") .foregroundStyle(.secondary) .imageScale(.medium) Text("Trash") .font(.headline) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) countBadge Spacer(minLength: 0) } .padding(.horizontal, 10) .padding(.vertical, 8) .background { UnevenRoundedRectangle(topLeadingRadius: cornerRadius, topTrailingRadius: cornerRadius) .fill(.quaternary.opacity(0.5)) .overlay { DiagonalHatch() .stroke(.quaternary, lineWidth: 1) .clipShape(UnevenRoundedRectangle( topLeadingRadius: cornerRadius, topTrailingRadius: cornerRadius )) } } .accessibilityElement(children: .combine) } /// The entry count — the same collection the body renders, so the badge cannot disagree with /// what is on screen (`LaneView.countBadge`'s rule, and it is why m5's filter needs no second /// change here). private var countBadge: some View { Text("\(entries.count)") .font(.caption) .monospacedDigit() .foregroundStyle(.secondary) .padding(.horizontal, 6) .padding(.vertical, 1) .background(Capsule().fill(.quaternary)) } // MARK: - Rows /// The rows, scrollable, with the navigation head kept in view. /// /// **"Selection scrolls into view"** (04-interactions.md ▸ Grammar), watching the head rather /// than the whole selection so exactly one column responds to any one arrow — `LaneView`'s rule, /// on the trash side. private var rows: some View { ScrollViewReader { proxy in scrollableRows .onChange(of: store.transient.selectionHead) { _, head in guard let head, entries.contains(where: { $0.id == head }) else { return } proxy.scrollTo(TrashSlot.identity(of: head)) } } } private var scrollableRows: some View { ScrollView(.vertical) { // **A plain `VStack`, deliberately not lazy.** Every row must keep its drawn frame // registered in `MarqueeTargetRegistry` — the rubber band sweeps those frames and the // arrows navigate by them (`NavigationMath`) — and a lazy stack only builds the rows it // 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(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(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 // reason: a `ScrollView` proposes its content only the height that content asks for, so a // view sized to fit its rows leaves the blank space beneath them un-hit-testable. "The // column's gesture surface is full height" (04-interactions.md ▸ The trash, settled) // needs that blank space to actually belong to the view the gesture below is on. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding(6) .contentShape(Rectangle()) // The band's trash-side surface. It arms from the column's empty space, full height // (above) included, so a drag can start from the blank area below the last row exactly // as the board background allows on the live side — a drag begun on a row instead is that // row's drag-out, and the begin guard makes that geometric rather than a matter of // gesture priority (`MarqueeControl`). .simultaneousGesture(marquee.gesture(side: .trashed)) } } } // 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, /// drawn rather than imaged so it takes whatever width the division gives the column. /// /// The lines start a full header-height to the left of the leading edge so the first stroke reaches /// the top-left corner instead of beginning partway across. private struct DiagonalHatch: Shape { var spacing: CGFloat = 7 func path(in rect: CGRect) -> Path { var path = Path() guard spacing > 0, rect.height > 0 else { return path } var x = rect.minX - rect.height while x < rect.maxX { path.move(to: CGPoint(x: x, y: rect.maxY)) path.addLine(to: CGPoint(x: x + rect.height, y: rect.minY)) x += spacing } return path } } // MARK: - Rows /// One trash row: a compact, dimmed plate carrying the item's symbol and title — and, for a lane /// entry, the count of cards Put Back would return with it. /// /// **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…. 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 let entry: TrashEntry let confirmations: TrashConfirmations let drops: BoardDropContext /// Where the rubber band looks up what it is sweeping — the card face's rule, on the trashed /// side (`View.marqueeTarget`). let registry: MarqueeTargetRegistry /// **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 /// still selects either way. @ViewBuilder var body: some View { if entry.isLaneEntry { plate } else { plate.onDrag(startRowDrag, preview: { dragReplica }) } } private var plate: some View { rowFace // The row being dragged out dims in place — the source stays visible in the trash, // because a restore is not a removal until the write lands. .opacity(drops.session.isDragging(entry.id) ? ClipboardTreatment.dimmedOpacity : 1) // The deferred cut wears the same dim wherever it lands, so the treatment is stated for // every surface a `pendingCut` could name rather than for two of the three. In practice // it never fires here: ⌘X is disabled on tombstoned selections (04-interactions.md ▸ The // trash), and a pending cut is homogeneous by liveness — a reload that tombstones a cut // card *ejects* it from the set rather than moving it to the other side. .cutTreatment(of: entry.id, in: store) .contentShape(Rectangle()) .onTapGesture { select() } .marqueeTarget(entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed, in: registry) .contextMenu { menu } } /// 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 { TrashRowPlate( symbol: ItemSymbol.name(entry.icon, fallback: symbolFallback), title: entry.title, subtitle: laneSubtitle, isSelected: isSelected ) } private var symbolFallback: String { 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 { store.selection.liveness == .trashed && store.selection.ids.contains(entry.id) } /// A click selects this row on the **trashed** side, through the same grammar the board's /// surfaces use — plain replaces, ⌘ toggles, ⇧ ranges (`SelectionGrammar`). /// /// The row's kind travels with the click, and that is what keeps the trash's second homogeneity /// axis true: a ⌘-click across the card/lane-entry boundary replaces rather than mixing, and a /// ⇧-range walks only its own kind's rows (04-interactions.md ▸ The trash). No `togglesOnRepeat` /// — click-again-to-unselect is the lane's behaviour, not a row's. /// /// **A double click is two of these and nothing more**: no editor, no card window, no timer. private func select() { store.click( SelectionTarget(id: entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed), modifier: .current ) } // MARK: - Drag out /// Begins the row's drag out of the trash — an ordinary **card session on the trashed side**, /// which is the whole of what makes it a restore rather than a move (04-interactions.md ▸ The /// trash; `DragLocality.operation`). /// /// Where it lands is the ordinary drop model's answer: `DropSlotMath.cardSlot` over the /// destination lane's masonry, so "restores it at the drop position" is the same arithmetic every /// other card drop uses. What a release *means* differs by locality and modifier, and that lives /// in one place (`BoardDropContext.commitDrop`): within the board a restore, ⌥ a live copy-out, /// across boards a live copy with ⌘ forcing the true restore-move. /// /// **Multi-drag carries the whole trashed selection**, in the trash's own sorted order — the /// order the rows are drawn in, which is the only relative order a set of tombstones has. /// /// Refused under the read-only lock and while an inline editor is focused, like every other /// mutating gesture. private func startRowDrag() -> NSItemProvider { guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() } let selection = store.selection let ids: Set = selection.liveness == .trashed && selection.ids.contains(entry.id) && selection.ids.count > 1 ? selection.ids : [entry.id] // Card entries only: a lane entry cannot be dragged at all, so one caught up in a mixed // selection is simply not carried. (The selection is homogeneous by kind anyway — this is // belt over braces.) let rows: [(id: ItemID, laneID: ItemID, title: String?)] = TrashModel.entries(of: store.snapshot) .compactMap { candidate in guard ids.contains(candidate.id), case let .card(card, laneID) = candidate else { return nil } return (card.id, laneID, card.title.value) } guard !rows.isEmpty else { return NSItemProvider() } let root = store.rootURL let payload = DragPayload( boardRoot: root, kind: .cards, side: .trashed, items: rows.map { DragPayload.Item( id: $0.id.rawValue, folder: TrashModel.ItemPath(laneID: $0.laneID, cardID: $0.id).folder(under: root).path, title: $0.title ) } ) drops.session.beginCards( rows.map(\.id), folders: payload.folders, // What a cross-board arrival's overlay draws with (`DroppedItem`) — the payload's own // titles, which for a trash row are the tombstone's. titles: payload.items.map(\.title), heights: rows.map { _ in LaneDropRegistry.nominalCardHeight }, side: .trashed, source: store ) return payload.itemProvider() } /// The image under the cursor: the row as it is drawn, fanned with a count badge for a /// multi-drag — the card replica's treatment, at a trash row's size. private var dragReplica: some View { let count = store.selection.liveness == .trashed && store.selection.ids.contains(entry.id) ? max(1, store.selection.ids.count) : 1 return ZStack { if count > 2 { rowFace.offset(x: 10, y: 10).opacity(0.45) } if count > 1 { rowFace.offset(x: 5, y: 5).opacity(0.7) } rowFace } .frame(width: 200) .overlay(alignment: .topTrailing) { DragCountBadge(count: count) } .padding(12) } // MARK: - The trash entry's context menu /// Put Back, Delete Immediately, Reveal in Finder — the three rows 11-command-nexus.md gives a /// trash entry, and no others. /// /// Reveal in Finder is the odd one out and deliberately so: it is "not edit-shaped and stays /// enabled on tombstoned selections", read-only lock included — inspecting a folder before a /// purge is exactly the errand it exists for. @ViewBuilder private var menu: some View { Button("Put Back") { store.putBack(targetIDs) } .disabled(!store.acceptsBoardMutations) Button("Delete Immediately") { confirmations.requestPurge(of: targetIDs, in: store) } .disabled(!store.acceptsBoardMutations) Divider() Button("Reveal in Finder") { NSWorkspace.shared.activateFileViewerSelecting(targetFolders) } } /// What this row's menu acts on: the whole selection when this row is part of it, else this row /// alone — standard macOS context-menu targeting, and the same rule the card face and the lane /// header apply to Style…. Right-clicking something outside the selection acts on what was /// clicked, which is also what keeps a cross-kind menu from ever acting on a mixed set. private var targetIDs: Set { guard store.selection.liveness == .trashed, store.selection.ids.contains(entry.id) else { return [entry.id] } return store.selection.ids } private var targetFolders: [URL] { TrashModel.paths(of: targetIDs, on: .trashed, in: store.snapshot) .map { $0.folder(under: store.rootURL) } } }