import AppKit import Observation import SwiftUI // MARK: - TrashLaneView /// The trash column: the trailing, visually distinct column the board's `.trash/` cards live in /// (03-board-ui.md § Trash, resettled 2026-07-28 — the materialized trash). /// /// ### A rendering of `snapshot.trash`, and a quasi-lane /// /// **Its contents are ordinary cards in a special place**, so there is nothing to derive: the column /// renders `store.snapshot.trash`, which the loader parsed with the same card parse the lanes use /// and sorted by `order` like any lane's children. Newest-first falls out of the ranks (every /// arrival mints one above the current top), so there is no timestamp sort and no entry type here at /// all. 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 lanes; /// - it has **no new-card button**: nothing is created in the trash. /// /// ### The drop it takes, and the drag it starts /// /// **"Dropping a live card on the shown trash deletes it"** (04-interactions.md ▸ The trash) — the /// drag becomes the pointer's delete gesture, and release moves the dragged card(s) into `.trash/`. /// So the column declares an `onDrop` (`TrashDropDelegate`), and it is the narrowest one on the /// board: a board card drag from **this** board, unmodified. It diverges from every other drop in one /// way, and the ranks are what make the divergence honest: **the shadow always takes the topmost /// row**, because every arrival mints a rank above the current top. /// /// The drag *out* is the restore, and it is deliberately not special: a trash card's drag is an /// ordinary `.cards` session in the `.trash` container, and `BoardDropContext.commitDrop` hands it /// to the same `moveCards`/`copyCards`/`receiveCards` every board card uses. "Restoring is an /// ordinary move out … there is no restore-specific machinery and no Put Back" (03 § Trash). /// /// **Finder file drops stay inert** — "Finder file drops on trash cards are inert" (▸ The trash) — /// and say so directly: the delegate clears the file highlight over the column. /// /// ### No editing in the trash /// /// "No editing in the trash: trash cards don't open — double-click stops at selection; move it out /// first." There is deliberately no double-tap recogniser, no rename editor, and no Style… row. /// /// ### What is still phase 3's /// /// The column renders the materialized trash correctly and its selection, drag, drop, filter and /// context menu all speak the new container vocabulary — but its *visual* treatment is still the /// tombstone era's compact dimmed plate rather than the card face 03 now implies ("a trashed card is /// an ordinary card in a special place"). Reworking the plate into the ordinary face, and the /// accessibility labelling 10-accessibility.md asks for, is the trash's own phase-3 card. 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 card's drag is an ordinary card session in the /// **trash** container (`DragSession`, 04-interactions.md ▸ The trash). let drops: BoardDropContext /// The strip's rubber band. The column's empty space is its third surface, in the **trash** /// container — "the rubber band stays on the side 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) 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 cards the column shows. /// /// **Shown, the trash's cards "participate in the filter exactly like any other card"** /// (03-board-ui.md § Trash — "the point of the pivot"), so the search predicate narrows this /// collection exactly as it narrows `LaneView.renderedCards`, 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 cards: [Card] { let filter = store.searchFilter return store.snapshot.trash.filter { filter.matches($0) } } // MARK: - The delete gesture's landing /// Where the delete gesture's shadow run opens in this column — always the topmost row — or `nil` /// when no proposal names the trash, which is every other moment of the app's life. private var proposal: Int? { drops.session.trashProposal(onBoardRooted: store.rootURL) } /// The rows the `VStack` lays out: the entries, with the delete gesture's shadow run opened at /// the top. /// /// The run stands until the echo reload brings the real tombstones — the committed-overlay hold /// keeps the arrangement the release proposed on screen for that round trip, exactly as every /// other container's does (`CommittedHold`). private var slots: [TrashSlot] { var result = cards.map(TrashSlot.card) guard let proposal else { return result } let run = (0.. 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 card's symbol and title. /// /// **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. (Making it the ordinary card face is phase 3's — see /// `TrashLaneView`.) private struct TrashCardRow: View { let store: BoardStore let card: Card let confirmations: TrashConfirmations let drops: BoardDropContext /// Where the rubber band looks up what it is sweeping — the card face's rule, in the trash /// container (`View.marqueeTarget`). let registry: MarqueeTargetRegistry var body: some View { 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(card.id) ? ClipboardTreatment.dimmedOpacity : 1) // The deferred cut wears the same dim wherever it lands. It genuinely fires here now: // "⌘X works — cut in the trash, paste into a lane is the keyboard-native restore" // (04-interactions.md ▸ The trash, resettled 2026-07-28). .cutTreatment(of: card.id, in: store) .contentShape(Rectangle()) .onTapGesture { select() } .marqueeTarget(card.id, kind: .card, container: .trash, in: registry) .contextMenu { menu } } /// This card 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(card.icon, fallback: ItemSymbol.card), title: card.title.value, isSelected: isSelected ) } // MARK: - Selection private var isSelected: Bool { store.selection.container == .trash && store.selection.ids.contains(card.id) } /// A click selects this card in the **trash** container, through the same grammar the board's /// surfaces use — plain replaces, ⌘ toggles, ⇧ ranges (`SelectionGrammar`). /// /// The container travels with the click, and that is what keeps the one remaining homogeneity /// boundary true: a ⌘-click across it replaces rather than mixing (04-interactions.md ▸ The /// trash). There is no kind axis inside the trash any more — lanes are never trashed. No /// `togglesOnRepeat` — click-again-to-unselect is the lane's behaviour, not a card'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: card.id, kind: .card, container: .trash), modifier: .current ) } // MARK: - Drag out /// Begins the card's drag out of the trash — an ordinary **card session in the trash container**, /// which is the whole of what makes it a restore (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 "an ordinary move to the drop position" is the same arithmetic /// every other card drop uses, committed by the same `moveCards`. /// /// **Multi-drag carries the whole trash selection**, in the column's own order — the order the /// rows are drawn in, which is `order` ascending like any lane's. /// /// 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.container == .trash && selection.ids.contains(card.id) && selection.ids.count > 1 ? selection.ids : [card.id] let rows = store.snapshot.trash.filter { ids.contains($0.id) } guard !rows.isEmpty else { return NSItemProvider() } let root = store.rootURL let payload = DragPayload( boardRoot: root, kind: .cards, container: .trash, items: rows.map { DragPayload.Item( id: $0.id.rawValue, folder: ItemPath.trashCard($0.id).folder(under: root).path, title: $0.title.value ) } ) drops.session.beginCards( rows.map(\.id), folders: payload.folders, heights: rows.map { _ in LaneDropRegistry.nominalCardHeight }, container: .trash, 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.container == .trash && store.selection.ids.contains(card.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 card's context menu /// Delete and Reveal in Finder — the two rows 11-command-nexus.md gives a trash card, and no /// others ("Trash cards | Delete (permanent — 03's recoverability confirm), Reveal in Finder"). /// /// **Put Back is gone** with the tombstone model: restoring is drag-out or ⌘X/⌘V (03 § Trash). /// Reveal in Finder is the odd one out and deliberately so: it is "not edit-shaped and stays /// enabled on trash 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("Delete") { 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…. private var targetIDs: Set { guard store.selection.container == .trash, store.selection.ids.contains(card.id) else { return [card.id] } return store.selection.ids } private var targetFolders: [URL] { ItemPath.resolve(targetIDs, in: .trash, snapshot: store.snapshot) .map { $0.folder(under: store.rootURL) } } }