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). /// /// ### Ordinary cards, in a column that says where they are /// /// **Its contents are ordinary cards in a special place**, so there is nothing to derive and nothing /// to draw differently: 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 — through the very /// same `CardFaceView` a lane renders. "A trashed card is an ordinary card in a special place — /// search, selection, rendering, styling, and clipboard all treat it exactly like any other card" /// (03 § Trash), and one view is the only way to make *rendering* literally true rather than /// approximately so: a trashed card keeps its icon, its icon tint, its left-edge accent stripe, its /// attachments chip and its four-line title, because it is the same card and the same face. /// /// 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. /// /// ### What makes it a column and not a lane /// /// The chrome 03 asks for, and nothing beyond it — "trailing (rightmost) position when shown, /// visually distinct — dimmed/hatched header, trash SF Symbol, count badge; no new-card button; not /// draggable, not resizable, excluded from lane reordering": /// /// - 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. There is deliberately no Empty /// Trash button either — that command's home is File ▸ Empty Trash… (⇧⌘⌫), and 11-command-nexus.md /// gives the column no pointer affordance of its own. /// /// ### 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 (`CardFaceView.startTrashCardDrag`), 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 twice: the delegate clears the file highlight over the column, and the face's hover /// treatment is board-only (`CardFaceRole`). /// /// ### No editing in the trash /// /// "No editing in the trash: trash cards don't open — double-click stops at selection; move it out /// first." That is the face's `.trash` role: no double-tap recogniser, no rename editor, no Style… /// rows — absences rather than a pile of `disabled` modifiers. /// /// ### Accessibility /// /// "When shown, it is the last container, labeled as Trash with its count. Its cards are ordinary /// card elements" (10-accessibility.md, resettled 2026-07-28). The container name and value are set /// here; the elements inside are ordinary card faces because they *are* ordinary card faces. The full /// element tree — labels, values, traits, actions — is the accessibility milestone's. 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 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 card face registers its frame into the same registry. let marquee: MarqueeControl /// Reduce Motion, for the card transition below — 10-accessibility.md names the trash /// specifically ("and trash animations all get reduced variants"). @Environment(\.accessibilityReduceMotion) private var reduceMotion /// Reduce Transparency, for the column's two washes below — "glass underlays go solid, wherever /// they appear" (10-accessibility.md). The trash plate and its hatched header are the board's /// only translucent surfaces, and they sit over a background the *user* chose (03-board-ui.md § /// Styling), which is exactly the case the setting exists for (`Accommodations.Wash`). @Environment(\.accessibilityReduceTransparency) private var reduceTransparency /// The live body metric — this column's geometry is `LaneView`'s, derived from the same font /// (`BoardMetrics`), because these are the same cards in a column that must read as their /// sibling. private var pointSize: CGFloat { BoardMetrics.bodyPointSize } /// 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 var cornerRadius: CGFloat { BoardMetrics.laneCornerRadius(bodyPointSize: pointSize) } /// Between the cards — `LaneView.cardSpacing`, because these are the same cards. private var cardSpacing: CGFloat { BoardMetrics.cardSpacing(bodyPointSize: pointSize) } var body: some View { VStack(alignment: .leading, spacing: 0) { header cards } .background( RoundedRectangle(cornerRadius: cornerRadius) .fill(Accommodations.trashPlateWash(reduceTransparency: reduceTransparency).style) ) // **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 last container, labeled as Trash with its count" (10-accessibility.md ▸ Trash lane). // `.contain` rather than `.combine`: the cards inside are ordinary card elements and must // stay individually reachable — combining them would collapse the container the design asks // VoiceOver to enter. .accessibilityElement(children: .contain) .accessibilityLabel(AccessibilityPhrases.trashLabel) // The **rendered** count, like a lane's: the shown trash's cards participate in the filter, // so a query narrows the spoken count exactly as it narrows the badge and the column itself. .accessibilityValue(AccessibilityPhrases.trashValue(cards: renderedCards.count)) } /// 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. /// /// **A card being dragged out renders here anyway**, unlike a lane's: the source stays visible in /// the trash while the session is in flight, dimmed by the face's own treatment, because a /// restore is not a removal until the write lands. private var renderedCards: [Card] { Self.rendered(store.snapshot.trash, filter: store.searchFilter) } /// `renderedCards` as a pure function of its two inputs — `LaneView.rendered`'s trash-side twin, /// split out for the same reason: so the rule can be pinned without a view /// (`SearchFilterTests`). The column, its count badge and its marquee registration all read the /// property, which reads this. /// /// Two of the lane's four inputs are absent, and each absence is a ruling: **no rename /// exemption**, because nothing renames in the trash (04 ▸ The trash), and **no drag hiding**, /// because a card dragged *out* of the trash stays visible in it until the write lands. nonisolated static func rendered(_ trash: [Card], filter: SearchFilter) -> [Card] { 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 slots the column lays out: the cards, with the delete gesture's shadow run opened at the /// top. /// /// The run stands until the echo reload brings the real cards — 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 = renderedCards.map(TrashSlot.card) guard let proposal else { return result } let run = (0.. String { "trash:\(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. /// /// The pitch is the caller's (`BoardMetrics.trashHatchSpacing`), because it scales with the text: /// the hatch is the trash's non-colour distinction (10-accessibility.md's never-colour-alone rule), /// and a distinction that stops reading at a large text size is not one. private struct DiagonalHatch: Shape { var spacing: CGFloat 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 } }