View ▸ Zoom In / Zoom Out / Actual Size (⌘+ / ⌘− / ⌘0): 75%–200% in eight rungs, app-wide and persisted (the Show Comments precedent) — a viewing comfort, not a property of any one board. The level travels as BoardZoomContext in the environment, injected on BoardView alone so the banner strip, search bar, sheets and popovers stay at the system size; the environment is also what carries it through CardFaceView's equality gate, which compares nothing that moves with the level. Every BoardMetrics figure follows zoom.bodyPointSize — card and lane chrome, drag replicas and the count badge, the resize handle, the trash column — and the drop registry carries the ruler for event-time reads, with the autoscroller's three reaches turning font-derived (reachSide named as the stripGap it always equalled). Lanes still divide the window; zoom never moves the window or its floor. The toolbar gains a catalog-only Zoom In/Out pair mirroring the menu rows' predicate; zoom holds shut mid-drag (frozen geometry), each rung announces itself to VoiceOver, and the render suite pins both invariants: a rung repaints every face, a no-op Actual Size repaints nothing. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
470 lines
27 KiB
Swift
470 lines
27 KiB
Swift
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) }
|
|
|
|
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..<drops.session.shadowCount).map(TrashSlot.shadow)
|
|
result.insert(contentsOf: run, at: min(max(0, proposal), result.count))
|
|
return result
|
|
}
|
|
|
|
// 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. Now
|
|
/// that the cards inside wear their ordinary faces, this header is the *whole* of "you are
|
|
/// looking at the trash", which is why it keeps its full treatment rather than softening.
|
|
/// **State is never colour-alone** (10-accessibility.md): the header is hatched *plus* labeled.
|
|
///
|
|
/// It carries no gesture at all: no selection (the column "is never selectable as a lane"), no
|
|
/// reorder drag, no context menu.
|
|
private var header: some View {
|
|
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.laneHeaderSpacing(bodyPointSize: pointSize)) {
|
|
Image(systemName: "trash")
|
|
.foregroundStyle(.secondary)
|
|
.imageScale(.medium)
|
|
Text("Trash")
|
|
.boardFont(.headline)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
countBadge
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.horizontal, BoardMetrics.trashHeaderHorizontalPadding(bodyPointSize: pointSize))
|
|
.padding(.vertical, BoardMetrics.trashHeaderVerticalPadding(bodyPointSize: pointSize))
|
|
.background {
|
|
UnevenRoundedRectangle(topLeadingRadius: cornerRadius, topTrailingRadius: cornerRadius)
|
|
.fill(Accommodations.trashHeaderWash(reduceTransparency: reduceTransparency).style)
|
|
.overlay {
|
|
// The hatch pitch scales with the text (`BoardMetrics.trashHatchSpacing`): this
|
|
// is the trash's **non-colour** distinction, and a fixed 7pt pitch behind a
|
|
// headline twice its usual size reads as a texture rather than as hatching.
|
|
DiagonalHatch(spacing: BoardMetrics.trashHatchSpacing(bodyPointSize: pointSize))
|
|
.stroke(.quaternary, lineWidth: 1)
|
|
.clipShape(UnevenRoundedRectangle(
|
|
topLeadingRadius: cornerRadius,
|
|
topTrailingRadius: cornerRadius
|
|
))
|
|
}
|
|
}
|
|
// **The board's computed contrast ink** (10-accessibility.md; `BoardTextInk`) — the trash
|
|
// header is the second and last place on the board where text sits on the user's colour,
|
|
// whether that colour came from the palette grid or from a hand-written hex.
|
|
//
|
|
// The wash between the text and the board background does not save it: `.quaternary` at 50%
|
|
// is a ~5%-alpha tint (`Accommodations.trashHeaderWash`), so the glyphs are effectively on
|
|
// the board colour, and the decision is deliberately taken against that colour rather than
|
|
// against a three-layer composite — modelling a tint that shifts the surface by a couple of
|
|
// luminance points would add a whole layer of arithmetic to move no decision.
|
|
//
|
|
// **Outside the `.background`, unlike the lane's**, so the wash and the hatch flip with the
|
|
// text: both are drawn in `.quaternary` — the same label vocabulary — and a hatch left in the
|
|
// window's appearance over an inverted board is a dark texture on a dark surface, which
|
|
// would quietly cost the trash the *non-colour* distinction 10-accessibility.md's
|
|
// "state is never colour-alone" requires of it.
|
|
.boardTextInk(BoardTextInk.scheme(
|
|
forBoardBackground: store.snapshot.background,
|
|
appearance: colorScheme
|
|
))
|
|
// The container carries the label and the count (see `body`), so the header itself is
|
|
// decoration for VoiceOver rather than a second element saying the same thing.
|
|
.accessibilityHidden(true)
|
|
}
|
|
|
|
/// The row count — the same collection the body renders, so the badge cannot disagree with
|
|
/// what is on screen (`LaneView.countBadge`'s rule).
|
|
///
|
|
/// **Rows, not cards**: a lane row is a row in this column, and a badge reading `3` above four
|
|
/// drawn rows would break the one property this badge has. The *breakdown* is the spoken value's
|
|
/// (`AccessibilityPhrases.trashValue`) and the purge confirmations' (`TrashModel.Freight`), where
|
|
/// the difference between a card and a lane's freight actually matters.
|
|
private var countBadge: some View {
|
|
Text("\(renderedRows.count)")
|
|
.boardFont(.caption)
|
|
.monospacedDigit()
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal, BoardMetrics.badgeHorizontalPadding(bodyPointSize: pointSize))
|
|
.padding(.vertical, BoardMetrics.badgeVerticalPadding(bodyPointSize: pointSize))
|
|
.background(Capsule().fill(.quaternary))
|
|
}
|
|
|
|
// MARK: - Cards
|
|
|
|
/// 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. Either kind of row can be the head: plain arrows walk them all.
|
|
private var cards: some View {
|
|
ScrollViewReader { proxy in
|
|
scrollableCards
|
|
.onChange(of: store.transient.selectionHead) { _, head in
|
|
guard let head, renderedRows.contains(where: { $0.id == head }) else { return }
|
|
proxy.scrollTo(TrashSlot.identity(of: head))
|
|
}
|
|
}
|
|
}
|
|
|
|
private var scrollableCards: some View {
|
|
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)):
|
|
CardFaceView(
|
|
store: store,
|
|
card: card,
|
|
role: .trash(confirmations: confirmations),
|
|
marquee: marquee,
|
|
drops: drops
|
|
)
|
|
// The value gate, `LaneView`'s rule on the trash side (`CardFaceView.==`).
|
|
.equatable()
|
|
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
|
|
)
|
|
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)
|
|
// `maxHeight: .infinity` here, not just `maxWidth`, is what makes the gesture surface
|
|
// below reach the column's full height rather than stopping where the last card ends —
|
|
// the same fix `LaneView.scrollableCards` applies to its masonry, and for the identical
|
|
// reason: a `ScrollView` proposes its content only the height that content asks for, so 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.
|
|
.frame(maxWidth: .infinity, 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))
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|