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.., selectedCount: Int ) -> some View { CardFaceView( store: store, card: card, role: .trash(confirmations: confirmations), marquee: marquee, drops: drops, hero: CardHero.imageURL(for: card, inContainer: cardsFolder), isSelected: selectedIDs.contains(card.id), selectedCount: selectedIDs.contains(card.id) ? selectedCount : 1 ) // The value gate, `LaneView`'s rule on the trash side (`CardFaceView.==`). .equatable() } private var scrollableCards: some View { // **The trash-side selection, read once for the whole column — and this is a new subscription, // deliberately.** `LaneView` was already reading the selection for its header, so hoisting it // there was free; this body was not, and now is. The trade is the point: one column body per // selection change, in place of one body per *row* in it, which is what every face and row // here cost while each read `store.selection` for itself (`CardFaceView.isSelected`, // RENDER-INSTRUMENTATION.md ▸ Selection is O(board) in card bodies). A trash holds one board's // deletions, so even the bad side of that trade is small — but the shape is what matters, and // the shape is now O(1) bodies rather than O(rows). let selection = store.selection let selectedIDs = selection.container == .trash ? selection.ids : [] let selectedCount = max(1, selectedIDs.count) // The trash's own folder — the container its cards resolve their heroes against, `LaneView`'s // hoist one container over. A trashed card is an ordinary card in a special place, so it wears // its hero exactly as it did in its lane; only the folder its attachments now sit under moved. let cardsFolder = BoardWriter.trashFolder(inBoard: store.rootURL) return ScrollView(.vertical) { // **`MasonryLayout` at one column, and a plain `VStack` deliberately not.** The trash is // one width unit, so its masonry is a single column — but it is the *same* layout the // lanes use, which is what makes the drag's make-room reflow read as positional slides // here exactly as it does there (DRAG-REORDER.md § The card masonry). // // Not lazy, for `LaneView`'s reason squared: every face 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 deletions, and Empty Trash… exists. MasonryLayout(columns: 1, spacing: cardSpacing) { ForEach(Array(slots.enumerated()), id: \.element.id) { index, slot in Group { switch slot { case let .entry(.card(card)): cardRow( card, cardsFolder: cardsFolder, selectedIDs: selectedIDs, selectedCount: selectedCount ) case let .entry(.lane(lane)): // The opaque unit's row — its own view, because "no styling accents" and // "never expandable" are exactly what a card face is not // (`TrashLaneRowView`, 03-board-ui.md § Trash). TrashLaneRowView( store: store, lane: lane, confirmations: confirmations, drops: drops, marquee: marquee, isSelected: selectedIDs.contains(lane.id), selectedCount: selectedIDs.contains(lane.id) ? selectedCount : 1 ) // The row's own gate (`TrashLaneRowView.==`) — worth having now that the // column's body re-runs on every selection change rather than only on a // reload. .equatable() case .shadow: // The delete gesture's shadow, holding the topmost row open // (04-interactions.md ▸ The trash). At the nominal card height: the cards // being proposed have no face here yet to be measured. DragShadow(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize)) .frame(maxWidth: .infinity) .frame(height: drops.registry.nominalCardHeight) } } // A slot is a row in this column, so it arrives and leaves in the card's dialect // whatever its kind — a delete files one in, a restore or a purge takes one out, // and both halves of that pair should read alike from either side of the strip. // A lane row takes the same transition deliberately: what enters and leaves here // is a *row*, and the strip's lane transition is about a column of the board. // The transaction is the reload's, like the lanes' (`Motion.reloadAnimates`). .transition(Motion.cardTransition(reduced: reduceMotion)) // `order`-keyed traversal, `LaneView`'s rule on the trash side. The column is // one masonry column, so geometry and `order` agree here and the priority is // belt over braces — written anyway because the *reason* it agrees is the // column's fixed one width unit, which is a layout fact rather than a // traversal guarantee, and a two-unit trash would silently read column-major. .accessibilitySortPriority(Double(slots.count - index)) // 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: proposal) // **The measured viewport is the rows' height floor** — this is what makes the gesture // surface below reach the column's full height rather than stopping where the last card // ends. `maxHeight: .infinity` alone could not: a `ScrollView` proposes *nothing* along // its scroll axis, so no frame maximum stretches the content, and a view sized to fit // its cards 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, so the measured floor // supplies it — less the plate padding, which sits inside the scroll content here and // would otherwise make an empty column scrollable by its own inset // (`LaneView.scrollableCards` is the twin, with its padding outside). .frame( maxWidth: .infinity, minHeight: max(0, scrollViewportHeight - 2 * BoardMetrics.lanePlatePadding(bodyPointSize: pointSize)), maxHeight: .infinity, alignment: .topLeading ) .padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize)) .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 card exactly // as the board background allows on the live side — a drag begun on a face instead is that // card's drag-out, and the begin guard makes that geometric rather than a matter of // gesture priority (`MarqueeControl`). .simultaneousGesture(marquee.gesture(in: .trash)) } // The floor's measurement — `LaneView`'s, on the trash side: the scroll view's own height // is the space the rows must cover for the empty-surface gesture above, and the content // growing to the floor never moves the floor. .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { scrollViewportHeight = $0 } } } // MARK: - What the column lays out /// One slot of the trash column — a row of either kind, or one slot of the delete gesture's run. /// /// `LaneSlot`'s smaller sibling — smaller by exactly one case, because nothing is ever created in the /// trash so there is no placeholder to stand in for it — 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. /// /// The two kinds share one case (`TrashEntry`) rather than taking one each, because the column's /// ordering question is about *rows*: a card and a lane row are interchangeable to everything here /// except the one `switch` that draws them. private enum TrashSlot: Identifiable { /// A row the snapshot's trash already holds — a card, or a trashed lane's opaque unit. case entry(TrashEntry) /// One of the drag's N shadows, holding the topmost rows open (04-interactions.md ▸ The trash). case shadow(index: Int) 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)" } } /// A row 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 { "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 } }