import AppKit import Observation import SwiftUI // MARK: - The strip's half of a trash row's drag /// What the strip lends the trash so a row can be dragged back onto the board (03-board-ui.md § /// Trash ▸ Drag-to-restore). /// /// `LaneHeaderDrag`'s sibling, and for its reason: the gesture lives on the row, but the one thing it /// needs — *which lane is under the cursor* — is the strip's geometry, and it must be read at gesture /// time rather than at body-evaluation time. @MainActor struct TrashRowDrag { /// The live lane under an x in strip coordinates, or `nil` when the point is not over one — a /// gap, the outer margin, or the trash itself (`LaneLayoutMath.laneIndex`). let laneUnder: (CGFloat) -> ItemID? } // MARK: - TrashDragSession /// Window-local state for an in-flight drag out of the trash — `LaneReorderSession`'s sibling, and /// deliberately as small. /// /// It holds only what the pointer contributes: which card, and which lane is currently under it. /// Everything else — the strip's geometry, the lanes themselves — is read fresh at render time, so a /// foreign reload mid-drag cannot leave this holding a stale board (04-interactions.md ▸ Drag and /// drop's re-grounding rule). @MainActor @Observable final class TrashDragSession { /// The card being dragged out of the trash; `nil` when idle. private(set) var cardID: ItemID? /// The live lane the pointer is over, or `nil` when it is over nothing droppable. Observed: the /// lanes read it to draw the drop highlight, which is this milestone's whole visual feedback. private(set) var targetLaneID: ItemID? /// How far the pointer must travel before a click on a row becomes a drag — the same threshold /// the lane header uses, so the two gestures feel alike. static let threshold: CGFloat = 4 var isActive: Bool { cardID != nil } func isDragging(_ id: ItemID) -> Bool { cardID == id } func isTarget(_ id: ItemID) -> Bool { targetLaneID == id } func begin(cardID: ItemID) { self.cardID = cardID targetLaneID = nil } func update(targetLaneID: ItemID?) { guard isActive else { return } self.targetLaneID = targetLaneID } /// Ends the drag, handing the caller nothing — the *commit* needs the current snapshot, which /// the view has and this session deliberately does not. Idempotent, because a gesture can end /// after the card it was carrying has already vanished. func end() { cardID = nil targetLaneID = nil } } // 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 reorder proposal's `unitCounts` by construction, since `BoardView` builds that from /// the snapshot's live lanes; /// - it has **no new-card button**: nothing is created in the trash. /// /// ### 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 /// /// The **search filter** ("shown, it participates in the filter like any lane") and **⌘C copy-out** /// are still owed. 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 strip's drop resolution — see `TrashRowDrag`. let drag: TrashRowDrag /// The window's one drag-out session, owned by `BoardView` for the same lifetime the reorder /// session has. let dragSession: TrashDragSession /// 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 var body: some View { VStack(alignment: .leading, spacing: 0) { header rows } .background( RoundedRectangle(cornerRadius: cornerRadius) .fill(.quaternary.opacity(0.35)) ) } /// The rows the column shows. /// // m5-search: the shown trash "participates in the filter like any lane", 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. private var entries: [TrashEntry] { TrashModel.entries(of: store.snapshot) } // 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(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(entries) { entry in TrashEntryRow( store: store, entry: entry, confirmations: confirmations, drag: drag, dragSession: dragSession, registry: marquee.registry ) // 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) } } .frame(maxWidth: .infinity, alignment: .topLeading) .padding(6) .contentShape(Rectangle()) // The band's trash-side surface. It only ever arms from the column's empty space — a // drag begun on a row 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 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…, 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. private struct TrashEntryRow: View { let store: BoardStore let entry: TrashEntry let confirmations: TrashConfirmations let drag: TrashRowDrag let dragSession: TrashDragSession /// Where the rubber band looks up what it is sweeping — the card face's rule, on the trashed /// side (`View.marqueeTarget`). let registry: MarqueeTargetRegistry private let cornerRadius: CGFloat = 6 var body: 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) ) // The row being dragged out dims further, so the gesture reads even without a replica. .opacity(dragSession.isDragging(entry.id) ? 0.45 : 1) .contentShape(Rectangle()) .gesture(rowGesture) .marqueeTarget(entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed, in: registry) .contextMenu { menu } } private var symbolFallback: String { entry.isLaneEntry ? ItemSymbol.lane : ItemSymbol.card } // 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 /// One gesture recognising the same click-versus-drag split the lane header uses: a plain click /// selects, and only movement past the threshold begins a drag out of the trash. /// /// **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 and the release still selects. /// /// The coordinate space is the strip's, because what the release needs is a *position* over the /// board, not a translation. private var rowGesture: some Gesture { DragGesture(minimumDistance: 0, coordinateSpace: .named(BoardView.stripSpace)) .onChanged { value in guard isDraggable, !store.isReadOnly, !store.isEditingInline else { return } if !dragSession.isDragging(entry.id) { let travelled = max(abs(value.translation.width), abs(value.translation.height)) guard travelled > TrashDragSession.threshold else { return } dragSession.begin(cardID: entry.id) } dragSession.update(targetLaneID: drag.laneUnder(value.location.x)) } .onEnded { value in guard dragSession.isDragging(entry.id) else { select() return } dragSession.end() // A drop over anything but a live lane — the trash itself, a gap, the outer margin — // writes nothing. There is no replica to snap back; the row never left. guard let lane = drag.laneUnder(value.location.x) else { return } store.restoreByDrag(cardID: entry.id, intoLane: lane) } } private var isDraggable: Bool { !entry.isLaneEntry } // 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) } } }