import AppKit import Observation import SwiftUI // MARK: - TrashLaneView /// The trash column: the trailing, visually distinct column the board's `.trash/` entries live in /// (03-board-ui.md § Trash, resettled 2026-07-28 — the materialized trash; lanes rejoined /// 2026-07-29). /// /// ### Ordinary cards, in a column that says where they are /// /// **Its cards 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 `modified` descending, the trash's own order — 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. /// /// ### And its other kind, which is the opposite case /// /// **A trashed lane is an opaque unit** — "one distinct dimmed row showing its title and held-card /// count … no styling accents, never expandable" (03 § Trash) — so it gets its own small view /// (`TrashLaneRowView`) rather than a third card-face role: what a card face draws is exactly what /// the design says this row must not. The two kinds **interleave by `modified` descending**, which is /// `BoardModel.trashEntries`' merge and not a second one written here. /// /// Newest-first falls out of the stamp (every arrival of either kind restamps `modified` on the way /// in — 03 § Trash, re-ruled 2026-07-31), so no rank is minted anywhere in this container. /// /// ### 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 — or lane — on the shown trash deletes it"** (04-interactions.md ▸ The /// trash, lanes extended 2026-07-29) — the drag becomes the pointer's delete gesture, and release /// moves the dragged item(s) into `.trash/`. So the column declares an `onDrop` /// (`TrashDropDelegate`), and it is the narrowest one on the board: a board drag from **this** board, /// unmodified. It diverges from every other drop in one way, and the stamp is what makes the /// divergence honest: **the shadow always takes the topmost row**, because every arrival restamps /// `modified` and the column is sorted by it, newest first. /// /// The drag *out* is the restore, and it is deliberately not special at either level: a trash card's /// drag is an ordinary `.cards` session in the `.trash` container /// (`CardFaceView.startTrashCardDrag`) and a trashed lane row's is an ordinary `.lanes` session in it /// (`TrashLaneRowView.startDrag`), which `BoardDropContext.commitDrop` hands to the same /// `moveCards`/`copyCards`/`receiveCards` and `restoreLanes`/`receiveLanes` every board item 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 card elements inside are ordinary card faces because they *are* ordinary card faces, and /// a trashed lane is "one flattened opaque element … never a container" (▸ Trash lane, lanes rejoined /// 2026-07-29), which is `TrashLaneRowView`'s own doing. 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 window's appearance — 10-accessibility.md's runtime-contrast input, read in `body` so a /// light/dark flip recomputes the header's ink (`LaneView.colorScheme`'s twin, for its reason). @Environment(\.colorScheme) private var colorScheme /// The board's ruler (`BoardZoom`) — the same one the live lanes read, since the trash column has /// to stay their sibling at every zoom level as well as at every text size. @Environment(\.boardZoom) private var zoom /// 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 { zoom.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) } /// The column's scroll viewport height — the rows' height *floor* (`scrollableCards`), measured /// for `LaneView.scrollViewportHeight`'s reason: a `ScrollView` proposes nothing along its /// scroll axis, so only an explicit minimum can stretch the content to fill it. @State private var scrollViewportHeight: CGFloat = 0 var body: some View { // The strip's third body level, observed (`BoardRenderMetrics`) — DEBUG only, and a // `let _` because `body` is a `@ViewBuilder` and a bare `Void` call is not a view. #if DEBUG let _ = BoardRenderMetrics.countTrashLaneBody() #endif 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** counts, like a lane's: the shown trash's rows participate in the filter, // so a query narrows the spoken count exactly as it narrows the badge and the column itself. // Both kinds are named, because both are rows the VoiceOver cursor is about to walk into. .accessibilityValue(AccessibilityPhrases.trashValue( cards: renderedCards.count, lanes: renderedRows.count - renderedCards.count )) } /// The rows the column shows — **both kinds, interleaved by `modified` descending** /// (03-board-ui.md § Trash: "Lane rows and cards interleave in the one trash column by `modified` /// descending"). /// /// **Shown, the trash's contents "participate in the filter exactly like any other card"** /// (03 § Trash — "the point of the pivot"), so the search predicate narrows this collection /// exactly as it narrows `LaneView.renderedCards`; a lane row matches by title alone, which is /// `SearchFilter`'s own rule for the opaque unit. Hidden, the column renders nothing and /// registers nothing, so "hidden trash is invisible to search" needs no code at all. /// /// **A row being dragged out renders here anyway**, unlike a lane's on the strip: the source /// stays visible in the trash while the session is in flight, dimmed by the row's own treatment, /// because a restore is not a removal until the write lands. private var renderedRows: [TrashEntry] { Self.rendered(store.snapshot, filter: store.searchFilter) } /// The card half of `renderedRows` — what the badge and the spoken card count read. private var renderedCards: [TrashEntry] { renderedRows.filter { $0.kind == .card } } /// `renderedRows` 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 row dragged *out* of the trash stays visible in it until the write lands. nonisolated static func rendered(_ snapshot: BoardModel, filter: SearchFilter) -> [TrashEntry] { snapshot.trashEntries.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.rootKey) } /// The slots the column lays out: the rows, with the delete gesture's shadow run opened at the /// top. /// /// The run stands until the echo reload brings the real rows — 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 = renderedRows.map(TrashSlot.entry) 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 } }