A selection change re-ran every CardFaceView on the board (180 bodies ≈ 85 ms on the 6×30 fixture, 515 ≈ 233 ms on a real 515-card board, debug): the face's body read store.selection in three places — isSelected, the drag replica's count, and the context menu's styleTarget — and Observation invalidates every reader of the property, past the equatable gate entirely. The band overlay stayed cheap, which is why the marquee tracked the cursor while the highlight lagged ~0.4 s behind. Now LaneView and TrashLaneView hoist one selection read per body and hand each face isSelected/selectedCount as compared parameters; StyleMenuItems takes its target as a deferred closure; TrashLaneRowView gains the same treatment plus the Equatable gate it never needed before. Select-one-card: 180 bodies → 1. A growing band costs the selection's own running size; the real board's crossing fell 233 → 112 ms — the remainder is lane bodies re-measuring their masonry, a separate lane-level finding recorded in RENDER-INSTRUMENTATION.md. Also: select() gains defaultsSoleMember — the marquee's explicit nils never avoided the sole-member default, so a one-card band acquired a selectionHead and could scroll the lane out from under its own drag. MarqueeRenderCostTests pins the shape: redundant samples cost zero bodies, a growing band pays per crossing, and selectionStillRepaints holds a ≤8 budget.
878 lines
50 KiB
Swift
878 lines
50 KiB
Swift
import AppKit
|
||
import SwiftUI
|
||
|
||
// MARK: - Which side of the board a face is on
|
||
|
||
/// **The one axis a card face has** — which container it is drawn in, and the collaborator that
|
||
/// container's grammar needs.
|
||
///
|
||
/// 03-board-ui.md § Trash, resettled 2026-07-28: "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". So there is **one** card face in this app, and this is the whole of what differs between
|
||
/// its two homes. Everything the two sides share — the plate, the stripe, the icon tint, the
|
||
/// attachments chip, the selection treatment, the cut dim, the marquee registration, the drag — is
|
||
/// shared by construction rather than by two views agreeing.
|
||
///
|
||
/// The three differences are all *absences on the trash side*, and each is 04-interactions.md ▸ The
|
||
/// trash's "everything edit-shaped is disabled on trash selections" showing up as a branch that is
|
||
/// simply not taken:
|
||
///
|
||
/// - **no Open** — no double-click gesture at all ("trash cards don't open — double-click stops at
|
||
/// selection"), which is also why `openCard` is the board case's payload rather than the view's;
|
||
/// - **no Rename** — the inline editor is board-only (`isRenaming`), so a rename that somehow
|
||
/// targeted a trashed card would render nothing rather than open a field over it;
|
||
/// - **no Style…** — no popover anchor, and no Style rows in the context menu.
|
||
///
|
||
/// Plus the two that are not about editing: Finder file drops are inert over the trash (▸ The trash),
|
||
/// so the file-hover highlight is board-only; and the trash's context-menu Delete is *permanent*, so
|
||
/// it needs the window's confirmation host (11-command-nexus.md ▸ Context menus' Trash cards row).
|
||
///
|
||
/// **Only the trash side carries the confirmation host** (settled): the board side's Delete is the
|
||
/// ordinary staged move into `.trash/` and never stands an alert, so `board` needs nothing beyond the
|
||
/// card opener.
|
||
/// `Sendable` because `CardFaceView.==` is nonisolated and a nonisolated context may only read a
|
||
/// main-actor `let` of Sendable type — which is why `openCard` is typed `@MainActor` (an isolated
|
||
/// function type is Sendable; a bare one is not, and would sink the whole enum).
|
||
enum CardFaceRole: Sendable {
|
||
|
||
/// A card in a lane. Carries the board window's card opener — ⌘↩'s pointer twin
|
||
/// (04-interactions.md ▸ Selection).
|
||
case board(openCard: @MainActor (ItemID) -> Void)
|
||
|
||
/// A card in `<root>/.trash/`. Carries the window's purge-alert host, because the trash's Delete
|
||
/// is the permanent one and "confirms exactly where the loss is real" (03 § Trash).
|
||
case trash(confirmations: TrashConfirmations)
|
||
|
||
/// Which container a click on this face selects in, which container its drag begins in, and which
|
||
/// container the rubber band sweeps it as — one answer, so the three can never disagree
|
||
/// (`SelectionGrammar`: "the container is the surface's, not the item's").
|
||
var container: ItemContainer {
|
||
switch self {
|
||
case .board: .board
|
||
case .trash: .trash
|
||
}
|
||
}
|
||
|
||
/// Whether two roles put the face in the same home with the same collaborator — the comparison
|
||
/// `CardFaceView.==` makes, and the reason this enum is not simply `Equatable`.
|
||
///
|
||
/// **The board case's `openCard` is deliberately not compared.** It is a closure the strip
|
||
/// rebuilds on every body pass, so comparing it is impossible and ignoring it is correct: it is a
|
||
/// pure hand-off to the host's `WindowGroup` key, identical in behaviour whatever closure object
|
||
/// carries it, and a face that changed *which window it opens into* would be a face in a
|
||
/// different window and therefore a different view identity entirely.
|
||
///
|
||
/// The trash case's `confirmations` **is** compared, by identity: it is window-lived state
|
||
/// (`@State` in `BoardWindowHost`), so identity is both cheap and meaningful, and it is the one
|
||
/// collaborator a role carries that the face actually reads state off.
|
||
nonisolated func isEquivalent(to other: CardFaceRole) -> Bool {
|
||
switch (self, other) {
|
||
case (.board, .board): true
|
||
case let (.trash(lhs), .trash(rhs)): lhs === rhs
|
||
default: false
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Card face
|
||
|
||
/// The card face: a rounded plate carrying a leading SF Symbol, the title (or its quiet "Untitled"
|
||
/// placeholder), a quiet trailing attachments indicator, and a left-edge colour accent stripe
|
||
/// (03-board-ui.md § Card face, § Styling ▸ Capabilities).
|
||
///
|
||
/// ### One face, two containers
|
||
///
|
||
/// **This is the trash's row too** (03-board-ui.md § Trash, resettled 2026-07-28 — the materialized
|
||
/// trash): "a trashed card is an ordinary card in a special place … rendering … treat it exactly like
|
||
/// any other card". The tombstone era's compact dimmed plate is retired with the tombstones it drew;
|
||
/// a trashed card wears its style, its stripe, its icon tint and its attachments chip exactly as it
|
||
/// did in its lane, because it is the same card and the same view. What the trash takes away is
|
||
/// listed on `CardFaceRole` and nowhere else.
|
||
///
|
||
/// ### Title-only, deliberately
|
||
///
|
||
/// **No body excerpt** — settled, "the face stays title-only … the old 'iterate on the card face
|
||
/// later' item is closed with no growth". The only face chip in scope is attachments, "a quiet
|
||
/// indicator when the card has files — the title dominates", which is why the paperclip is a
|
||
/// secondary-tinted caption and not a count pill: the eye should land on the title.
|
||
///
|
||
/// ### Two lenient fields, two different fallbacks
|
||
///
|
||
/// `icon` and `iconColor` are hand-written-only on cards (`iconColor` is **schema yes, control
|
||
/// no** — the app never offers a picker for it, but honours what an author writes). Both degrade
|
||
/// rather than fail: an unknown symbol name draws the level default (`ItemSymbol`), and a colour
|
||
/// value that resolves to nothing draws the standard secondary tint. `background` degrades a third
|
||
/// way — to **no stripe at all** — because there is no sensible default colour for "the author
|
||
/// meant something we can't read", and a wrong colour is worse than none. In every case the bytes
|
||
/// on disk are untouched (`Palette`, 01-storage-format.md § Frontmatter).
|
||
///
|
||
/// ### One presentation, selection styling only
|
||
///
|
||
/// The face is a top-aligned title row and its two decorations — the accent stripe and the
|
||
/// selection stroke — are shapes in overlays. **A card has one presentation** (resettled
|
||
/// 2026-07-28, reversing the pathfinder's selection-keyed carousel): selection changes only this
|
||
/// face's styling — the selection stroke below — and never its geometry, so the masonry never
|
||
/// reflows on a click and every consumer of a card's drawn frame (the drop model's resting grid,
|
||
/// the rubber band's sweep universe) can trust a selection change to leave it untouched. Viewing an
|
||
/// attachment's media is the card window's job (⌘↩ / double-click, 05-card-window.md), not the
|
||
/// face's — the paperclip chip below is the face's whole attachment story (03-board-ui.md § Card
|
||
/// face).
|
||
///
|
||
/// ### Equality gate
|
||
///
|
||
/// The face is `Equatable` and instantiated through `.equatable()` (`LaneView.scrollableCards`,
|
||
/// `TrashLaneView.scrollableCards`), because its parent re-runs for reasons that have nothing to do
|
||
/// with any one card: a lane's body re-evaluates on **every drop-proposal change** while a drag is
|
||
/// in flight, and without a gate that rebuilds every face in every lane on every cursor move. See
|
||
/// the `==` below for what the gate covers and what it deliberately does not.
|
||
struct CardFaceView: View, Equatable {
|
||
|
||
let store: BoardStore
|
||
let card: Card
|
||
|
||
/// Which side of the board this face is drawn on, and what that side's grammar needs — the view's
|
||
/// one axis (`CardFaceRole`).
|
||
let role: CardFaceRole
|
||
|
||
/// The strip's rubber band — the registry this face registers its drawn frame into.
|
||
let marquee: MarqueeControl
|
||
|
||
/// The board window's drop machinery: this face registers its measured height into the geometry
|
||
/// registry (the resting grid's input) and starts the card drag session from `.onDrag`.
|
||
let drops: BoardDropContext
|
||
|
||
/// Whether this card is in the selection **of its own container** — a parameter rather than a
|
||
/// read off the store, and that is the whole of the fix RENDER-INSTRUMENTATION.md ▸ "Selection is
|
||
/// O(board) in card bodies" asked for.
|
||
///
|
||
/// Observation tracks whole properties, so a body that reads `store.selection` is subscribed to
|
||
/// *every* selection change on the board — 180 faces re-running per marquee sample on the 6×30
|
||
/// fixture, 515 on a real board, and `.equatable()` powerless over any of it because a direct
|
||
/// Observation invalidation never consults the gate. Taking selected-ness as a compared input
|
||
/// instead moves the subscription up one level, to the parent that already holds it: the lane's
|
||
/// body reads the selection once for its own header, hands each face the answer, and the gate
|
||
/// below then re-runs exactly the faces whose flag actually flipped.
|
||
let isSelected: Bool
|
||
|
||
/// The size of the selection this face belongs to — **1 when unselected**, which the parent
|
||
/// computes and normalizes so the drag replica's fan and count badge need no store read of their
|
||
/// own (`dragReplica`). Deliberately not `Int?`: "how many ride along" has an answer for every
|
||
/// face, and one is it.
|
||
let selectedCount: Int
|
||
|
||
/// The app-wide quick-style recents — see `LaneView`'s own note.
|
||
@Environment(AppModel.self) private var appModel
|
||
|
||
/// Increase Contrast, for the plate's borders below (10-accessibility.md: "Increase Contrast
|
||
/// strengthens borders and the selection indicator"). Read from the environment and handed to
|
||
/// `Accommodations`, which owns what "increased" does to a stroke.
|
||
@Environment(\.colorSchemeContrast) private var contrast
|
||
|
||
/// The board's ruler (03-board-ui.md ▸ Layout — zoom; `BoardZoom`). **The environment is what
|
||
/// makes zoom reach a card face at all**: this view is `.equatable()`, and the gate above compares
|
||
/// nothing that moves with the level — but it does not compare environment values either, because
|
||
/// SwiftUI invalidates on those itself. A level threaded any other way would be swallowed here.
|
||
@Environment(\.boardZoom) private var zoom
|
||
|
||
/// The live body metric — every figure this face lays out on is a multiple of it
|
||
/// (`BoardMetrics`, 10-accessibility.md's full-relative-scaling rule). Read here rather than
|
||
/// passed in, which is `CardAttachmentsSection`'s pattern on the card-window side.
|
||
private var pointSize: CGFloat { zoom.bodyPointSize }
|
||
|
||
/// The plate's corner radius — shared with the accent stripe, which rounds its left corners to
|
||
/// exactly this so the stripe reads as part of the card's edge rather than a bar laid over it.
|
||
/// Read from `BoardMetrics` rather than spelled here, because the new-card placeholder has to
|
||
/// draw this same plate for the create handoff to read as one arrival.
|
||
private var cornerRadius: CGFloat { BoardMetrics.cardCornerRadius(bodyPointSize: pointSize) }
|
||
|
||
/// K1 · left edge stripe (03-board-ui.md § Styling ▸ Capabilities, settled in the pathfinder's
|
||
/// treatment shootout).
|
||
private var stripeWidth: CGFloat { BoardMetrics.cardStripeWidth(bodyPointSize: pointSize) }
|
||
|
||
/// This face's drawn width — **the replica's** (`replicaFace`). Measured rather than derived,
|
||
/// because a face is as wide as the interior masonry column its lane gives it, and that is a
|
||
/// function of the lane's slot width and its column count rather than of the font
|
||
/// (`LaneLayoutMath`, `MasonryLayout`). Zero until the first layout, which is what the metric's
|
||
/// fallback is for.
|
||
@State private var measuredWidth: CGFloat = 0
|
||
|
||
/// The whole of what this face is a function of **as far as its parent is concerned**: the card
|
||
/// value (`Card` is `Equatable` down to its attachment names and its parsed document), which home
|
||
/// it is drawn in (`CardFaceRole.isEquivalent(to:)`), the two selection figures the parent
|
||
/// resolves for it, and the three window-lived collaborators — the store by identity, the band
|
||
/// and the drop machinery by their own equivalence tests, which exist because the strip rebuilds
|
||
/// both structs, closures and all, on every body pass.
|
||
///
|
||
/// **Selection is a compared input now, and that is what makes the gate reach it.** It used to be
|
||
/// an Observation read — `isSelected` off `store.selection` — which meant a click anywhere on the
|
||
/// board invalidated every face on it *directly*, past the gate entirely, and the measured cost
|
||
/// of a marquee sample was the whole board's worth of bodies. Selected-ness now rides down as
|
||
/// `isSelected`/`selectedCount` on the parent's own subscription (`LaneView` already reads the
|
||
/// selection for its header; `TrashLaneView` gained one hoisted read to match), so a selection
|
||
/// change re-runs the lane bodies that were subscribed anyway plus exactly the faces whose flag
|
||
/// flipped. See RENDER-INSTRUMENTATION.md ▸ Selection is O(board) in card bodies.
|
||
///
|
||
/// **What the gate still does not suppress is the point.** What remains of this body's own
|
||
/// Observation reads — `store.transient.pendingCut` and the rename editor,
|
||
/// `drops.session.isDragging`, `appModel.styleRecents` — invalidates this view directly, and
|
||
/// `.equatable()` has no say in that. The gate only stops the *parent* handing a face a
|
||
/// new-but-identical set of inputs and re-running it for nothing, which during a card drag is
|
||
/// what every proposal change does to every face in the lane.
|
||
///
|
||
/// `store.searchFilter` is deliberately absent from that list: its only use here is `ownSlotSeed`,
|
||
/// reached from `startBoardCardDrag`, which runs when a drag begins rather than when the body
|
||
/// does — an event-time read subscribes nothing. `draggedIDs`, the context menus' `targetIDs` and
|
||
/// the deferred `styleTarget` read the selection the same way, inside actions, which is why they
|
||
/// stayed as they were.
|
||
///
|
||
/// Deliberately NOT compared: `@State` (per-identity, preserved across updates anyway),
|
||
/// `@Environment` values (SwiftUI invalidates on those itself), and the `.board` role's
|
||
/// `openCard` closure (see `CardFaceRole.isEquivalent(to:)`).
|
||
nonisolated static func == (lhs: CardFaceView, rhs: CardFaceView) -> Bool {
|
||
lhs.card == rhs.card
|
||
&& lhs.role.isEquivalent(to: rhs.role)
|
||
&& lhs.isSelected == rhs.isSelected
|
||
&& lhs.selectedCount == rhs.selectedCount
|
||
&& lhs.store === rhs.store
|
||
&& lhs.marquee.isEquivalent(to: rhs.marquee)
|
||
&& lhs.drops.isEquivalent(to: rhs.drops)
|
||
}
|
||
|
||
/// **The role's three absences, as a branch rather than as disabled modifiers.** Everything both
|
||
/// sides share is in `face`; what the board has and the trash does not is attached here, so the
|
||
/// trash's no-Open/no-Rename/no-Style is expressed by code that is not written rather than by
|
||
/// gestures that fire and refuse (`CardFaceRole`).
|
||
@ViewBuilder
|
||
var body: some View {
|
||
// The lane's gate, observed (`BoardRenderMetrics`) — DEBUG only, and a `let _` because a
|
||
// `@ViewBuilder` body takes statements as views and a bare call would be one.
|
||
#if DEBUG
|
||
let _ = BoardRenderMetrics.countCardBody()
|
||
#endif
|
||
switch role {
|
||
case let .board(openCard):
|
||
face
|
||
// "A fast double-click opens the card window (⌘↩'s pointer twin)" (04 ▸ Selection).
|
||
//
|
||
// `simultaneousGesture` rather than a second `onTapGesture(count: 2)`, deliberately: a
|
||
// second tap recogniser on the same view makes the single click *wait* to see whether a
|
||
// second one arrives, and selection must stay instant. Simultaneous means the first click
|
||
// of the pair selects and the second opens — Finder's own behaviour.
|
||
//
|
||
// **Plain only.** ⌘ and ⇧ double-clicks are selection gestures that happened twice; opening
|
||
// a window out from under a range the user is still building would be a surprise.
|
||
.simultaneousGesture(TapGesture(count: 2).onEnded {
|
||
guard ClickModifier.current == .plain else { return }
|
||
openCard(card.id)
|
||
})
|
||
.contextMenu { boardMenu(openCard: openCard) }
|
||
// **The menu's rows, additionally as custom actions** — "where SwiftUI additionally
|
||
// surfaces menu items as custom accessibility actions, that's free improvement, not
|
||
// a separate design surface" (10-accessibility.md ▸ Actions come from the context
|
||
// menu). The menu stays the inventory and stays reachable the standard way (VO-⇧-M).
|
||
// Style… is absent for `LaneView`'s reason: it opens a popover, and the quick-style
|
||
// swatch `Picker` beside it is not an action.
|
||
.accessibilityActions { boardActions(openCard: openCard) }
|
||
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
|
||
StyleEditorPopover(store: store, recents: appModel.styleRecents)
|
||
}
|
||
case let .trash(confirmations):
|
||
face
|
||
.contextMenu { trashMenu(confirmations: confirmations) }
|
||
// The trash's two rows and **no third** — "there is no Open"
|
||
// (10-accessibility.md ▸ Trash lane; 03-board-ui.md's no-editing-in-the-trash). The
|
||
// absence is structural on this side too: `openCard` is the board case's payload, so
|
||
// there is nothing here an Open action could even call.
|
||
.accessibilityActions { trashActions(confirmations: confirmations) }
|
||
}
|
||
}
|
||
|
||
/// Everything the two containers share — which, after the pivot, is the face itself.
|
||
private var face: some View {
|
||
titleRow
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
||
// Constant, whether or not a stripe paints: every card's text sits on the same grid, so
|
||
// colouring a card never shifts its title relative to its uncoloured neighbours.
|
||
.padding(.leading, stripeWidth)
|
||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(BoardSurface.cardPlate))
|
||
.overlay(alignment: .leading) { accentStripe }
|
||
// The selection treatment, which a hovering Finder file drag borrows outright: "the card
|
||
// highlights while hovered" (04-interactions.md ▸ Drag and drop), and the accent stroke is
|
||
// already this face's vocabulary for "this one". The hover draws it a touch heavier so a
|
||
// hovered card that is *also* selected still reads as the target.
|
||
//
|
||
// **Increase Contrast strengthens both the ring and the plate's edge** (10-accessibility.md):
|
||
// the stroke goes a point heavier, and an *unselected* card — which normally floats on its
|
||
// fill alone — gains a separator hairline, because "this is one card and that is another" is
|
||
// exactly the distinction the setting exists to rescue (`Accommodations`).
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: cornerRadius)
|
||
.strokeBorder(plateStroke, lineWidth: plateStrokeWidth)
|
||
)
|
||
// The deferred cut's dim (04-interactions.md ▸ Clipboard: "cut items dim in place until paste
|
||
// moves them"). Above `contentShape` so the face stays fully clickable while it waits. It
|
||
// genuinely fires on the trash side too: "⌘X works — cut in the trash, paste into a lane is
|
||
// the keyboard-native restore" (04 ▸ The trash, resettled 2026-07-28).
|
||
.cutTreatment(of: card.id, in: store)
|
||
// The face being dragged out dims the same way while the session is in flight, wherever the
|
||
// source stays visible — which is now two places rather than one. On the **trash** side a
|
||
// restore is not a removal until the write lands, so the row never leaves. On the **board**
|
||
// side a drag whose effective operation is `.copy` leaves its originals in the resting
|
||
// layout, because the copy leaves them there (`DragSession.hiddenMembers`), and this is what
|
||
// marks them as the source of the drag rather than as ordinary neighbours. A `.move`'s
|
||
// originals are lifted out entirely and this has nothing to act on
|
||
// (`LaneView.renderedCards`).
|
||
.opacity(drops.session.isDragging(card.id) ? ClipboardTreatment.dimmedOpacity : 1)
|
||
.contentShape(Rectangle())
|
||
// **A card is one flattened accessibility element** (10-accessibility.md ▸ The board through
|
||
// VoiceOver): "label = title (or the untitled placeholder), value carries the attachment
|
||
// count when present, selected state via trait. Face icon and chips are decorative — folded
|
||
// into the element, never separately focusable". So the icon, the accent stripe and the
|
||
// paperclip contribute nothing of their own — the count they stood for rides the value below.
|
||
//
|
||
// `.contain` while a rename is open, `LaneView`'s header rule for its reason: flattening
|
||
// would swallow the text field the user is typing into. Board-only by construction, since
|
||
// `isRenaming` is (`CardFaceRole`).
|
||
.accessibilityElement(children: isRenaming ? .contain : .ignore)
|
||
.accessibilityLabel(AccessibilityPhrases.cardLabel(title: card.title.value))
|
||
// The attachment count, the deferred cut's "cut, pending paste", or both — and the empty
|
||
// string when neither, which speaks as nothing (see `AccessibilityPhrases.cardValue` for why
|
||
// it is not a conditional modifier).
|
||
.accessibilityValue(AccessibilityPhrases.cardValue(
|
||
attachments: card.attachments.count,
|
||
isCutPending: store.transient.pendingCut.ids.contains(card.id)
|
||
))
|
||
// "Selection state is always readable from the element (trait)" — the other half of "state
|
||
// is never colour-alone", whose visible half is the accent stroke above.
|
||
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
|
||
// **VO-Space toggles this card's selection** — "moving the VoiceOver cursor never mutates
|
||
// selection. VO-Space on a card toggles its selection (the ⌘-click analogue — a toggle,
|
||
// never plain click's replace)". Routed through the same `BoardStore.click` funnel the
|
||
// pointer uses, with the ⌘ modifier, so the homogeneity rule and the container boundary are
|
||
// `SelectionGrammar`'s single answer rather than a second one written here.
|
||
.accessibilityAction { toggleSelection() }
|
||
// **Clicking never edits** (04-interactions.md ▸ Selection, a pivot from the pathfinder's
|
||
// two-stage Finder rename): one click selects and that is all it does — no timer, no
|
||
// slow-second-click rename, no accidental edit on a hesitant click. Rename is Return or
|
||
// Board ▸ Rename. The modifier grammar — plain replaces, ⌘ toggles, ⇧ ranges — is
|
||
// `SelectionGrammar`'s, reached through the store's one funnel.
|
||
//
|
||
// **The container travels with the click**, and that is what keeps the one remaining
|
||
// homogeneity boundary true: a ⌘-click across it replaces rather than mixing
|
||
// (04-interactions.md ▸ The trash). A double click in the trash is two of these and nothing
|
||
// more — no editor, no card window, no timer.
|
||
.onTapGesture {
|
||
store.click(SelectionTarget(id: card.id, kind: .card, container: role.container), modifier: .current)
|
||
}
|
||
// **The whole face is the drag surface** (04-interactions.md ▸ Drag and drop). `.onDrag`
|
||
// beside the tap recognisers above is the click-versus-drag split, the system's own: it holds
|
||
// the session off until the pointer really moves, so selecting and opening stay instant.
|
||
.onDrag(startDrag, preview: { dragReplica })
|
||
// The card's drawn size. Its **height** goes to the drop model's analytic resting grid — a
|
||
// height is content-driven and does not animate under the reflow, only positions do, and
|
||
// those are never measured (`LaneDropRegistry`). The trash side registers too: a trash card
|
||
// dragged out is an ordinary card session, and the shadow it opens in the destination lane
|
||
// should be its real footprint rather than the nominal guess.
|
||
//
|
||
// Its **width** stays here, for the drag replica: a face is as wide as its lane's interior
|
||
// column, which no metric can derive and only the laid-out face can report
|
||
// (`BoardMetrics.cardReplicaWidth(measured:bodyPointSize:)`).
|
||
.onGeometryChange(for: CGSize.self) { $0.size } action: { size in
|
||
drops.registry.update(height: size.height, for: card.id)
|
||
measuredWidth = size.width
|
||
}
|
||
.onDisappear { drops.registry.removeHeight(card.id) }
|
||
.marqueeTarget(card.id, kind: .card, container: role.container, in: marquee.registry)
|
||
}
|
||
|
||
// MARK: - The card drag
|
||
|
||
/// Begins this card's system drag session — in **its own container**, which is the whole of what
|
||
/// makes a trash card's drag a restore (04-interactions.md ▸ The trash; `DragLocality.operation`).
|
||
private func startDrag() -> NSItemProvider {
|
||
switch role.container {
|
||
case .board: startBoardCardDrag()
|
||
case .trash: startTrashCardDrag()
|
||
}
|
||
}
|
||
|
||
/// A lane card's drag (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
||
///
|
||
/// **Dragging any member of a multi-selection drags the whole selection**, in **flatten order** —
|
||
/// "lane `order` first, then card `order`", `SelectionGrammar.boardCards`' single definition of
|
||
/// it, which is also the order the drop inserts in. A card outside the selection drags alone.
|
||
///
|
||
/// Refused under the read-only lock and while an inline editor is focused, like every other
|
||
/// mutating gesture; a refusal is an item provider carrying nothing, so no session begins.
|
||
private func startBoardCardDrag() -> NSItemProvider {
|
||
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
||
let snapshot = store.snapshot
|
||
let ids = draggedIDs
|
||
|
||
// Flatten order, and the lane each member currently lives in — the folder path's middle
|
||
// component.
|
||
var lanesByCard: [ItemID: ItemID] = [:]
|
||
var titles: [ItemID: String] = [:]
|
||
for lane in snapshot.lanes {
|
||
for member in lane.cards where ids.contains(member.id) {
|
||
lanesByCard[member.id] = lane.id
|
||
titles[member.id] = member.title.value
|
||
}
|
||
}
|
||
let ordered = SelectionGrammar.boardCards(in: snapshot).filter { ids.contains($0) }
|
||
guard !ordered.isEmpty else { return NSItemProvider() }
|
||
|
||
let root = store.rootURL
|
||
let payload = DragPayload(
|
||
boardRoot: root,
|
||
kind: .cards,
|
||
container: .board,
|
||
items: ordered.compactMap { id in
|
||
guard let laneID = lanesByCard[id] else { return nil }
|
||
return DragPayload.Item(
|
||
id: id.rawValue,
|
||
folder: root
|
||
.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||
.appendingPathComponent(id.rawValue, isDirectory: true)
|
||
.path,
|
||
title: titles[id]
|
||
)
|
||
}
|
||
)
|
||
drops.session.beginCards(
|
||
ordered,
|
||
folders: payload.folders,
|
||
// The dragged items' sizes, frozen at drag start — the pickup transition scales the
|
||
// replica, and its lingering "last measured frame" would mis-size the shadow and the
|
||
// span-cap (03-board-ui.md § Motion).
|
||
heights: ordered.map { drops.registry.heights[$0] ?? drops.registry.nominalCardHeight },
|
||
container: .board,
|
||
source: store,
|
||
seed: ownSlotSeed(dragging: ids, laneID: lanesByCard[card.id], in: snapshot)
|
||
)
|
||
return payload.itemProvider()
|
||
}
|
||
|
||
/// The pickup's own-slot proposal (`DragSession.begin`): the grabbed card's position among its
|
||
/// lane's rendered cards with the dragged run lifted out — the same resting-layout index space
|
||
/// the retargets and `moveCards` count in, filtered by the same `LaneView.rendered` rules, so
|
||
/// the seeded shadow run stands exactly where the lifted cards stood and pickup moves nothing.
|
||
///
|
||
/// **A ⌥-pickup seeds nothing.** A copy's resting layout keeps the originals in place, so there
|
||
/// is no vacated space for a shadow to hold; the first `dropUpdated` resolves the copy and opens
|
||
/// the run beside the originals, exactly as it does today (DRAG-REORDER.md § Resting-layout
|
||
/// zones — the modifier reflow is the feedback being asked for).
|
||
private func ownSlotSeed(
|
||
dragging ids: Set<ItemID>,
|
||
laneID: ItemID?,
|
||
in snapshot: BoardModel
|
||
) -> DropTarget? {
|
||
guard !NSEvent.modifierFlags.contains(.option),
|
||
let laneID,
|
||
let laneCards = snapshot.lanes.first(where: { $0.id == laneID })?.cards
|
||
else { return nil }
|
||
let index = LaneView.rendered(
|
||
Array(laneCards.prefix { $0.id != card.id }),
|
||
hiddenByDrag: ids,
|
||
filter: store.searchFilter,
|
||
renaming: store.transient.renameEditor?.targetID
|
||
).count
|
||
return DropTarget(boardRoot: store.rootKey, container: .lane(laneID), index: index)
|
||
}
|
||
|
||
/// A trash card's drag out — **the restore**, and deliberately not special: an ordinary `.cards`
|
||
/// session in the `.trash` container, which `BoardDropContext.commitDrop` hands 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).
|
||
///
|
||
/// Where it lands is the ordinary drop model's answer: `DropSlotMath.cardSlot` over the
|
||
/// destination lane's masonry, so "an ordinary move to the drop position" is the same arithmetic
|
||
/// every other card drop uses, committed by the same `moveCards`.
|
||
///
|
||
/// **Multi-drag carries the whole trash selection**, in the column's own order — the order the
|
||
/// rows are drawn in, which since 2026-07-31 is `modified` descending (03 § Trash).
|
||
///
|
||
/// **A kind-blind selection can span both kinds, and the session says so** (04-interactions.md ▸
|
||
/// The trash, ruled 2026-07-31): the lane rows in it cannot ride a `.cards` session, so rather
|
||
/// than let them fall silently out of the payload the flag travels and the *drop* refuses with
|
||
/// the notice (`DragSession.mixesKinds`). Pickup stays allowed — the selection is legal.
|
||
private func startTrashCardDrag() -> NSItemProvider {
|
||
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
||
let ids = draggedIDs
|
||
let rows = store.snapshot.trash.filter { ids.contains($0.id) }
|
||
guard !rows.isEmpty else { return NSItemProvider() }
|
||
let mixesKinds = SelectionGrammar.mixesKinds(
|
||
ItemReferenceSet(ids: ids, container: .trash), in: store.snapshot)
|
||
|
||
let root = store.rootURL
|
||
let payload = DragPayload(
|
||
boardRoot: root,
|
||
kind: .cards,
|
||
container: .trash,
|
||
items: rows.map {
|
||
DragPayload.Item(
|
||
id: $0.id.rawValue,
|
||
folder: ItemPath.trashCard($0.id).folder(under: root).path,
|
||
title: $0.title.value
|
||
)
|
||
}
|
||
)
|
||
drops.session.beginCards(
|
||
rows.map(\.id),
|
||
folders: payload.folders,
|
||
heights: rows.map { drops.registry.heights[$0.id] ?? drops.registry.nominalCardHeight },
|
||
container: .trash,
|
||
source: store,
|
||
mixesKinds: mixesKinds
|
||
)
|
||
return payload.itemProvider()
|
||
}
|
||
|
||
/// What travels: the whole selection when this card is in it, else this card alone — the drag's
|
||
/// half of the context-menu targeting rule, and container-scoped like everything else.
|
||
private var draggedIDs: Set<ItemID> {
|
||
let selection = store.selection
|
||
guard selection.container == role.container,
|
||
selection.ids.contains(card.id),
|
||
selection.ids.count > 1
|
||
else { return [card.id] }
|
||
return selection.ids
|
||
}
|
||
|
||
/// The image travelling under the cursor: this card's face at its real size, fanned with ghosts
|
||
/// and a count badge when the whole multi-selection rides along (03-board-ui.md § Motion).
|
||
///
|
||
/// The count is `selectedCount`, not a fresh read of the selection: `.onDrag(_:preview:)`'s
|
||
/// preview builder is **non-escaping**, so it evaluates while the body does, and a `store.selection`
|
||
/// read here would have kept every face on the board subscribed no matter what the rest of this
|
||
/// view did. The parent already normalizes the figure to 1 for an unselected face, so the branch
|
||
/// that used to compute it is gone rather than moved (see `isSelected`'s note); the `max` is belt
|
||
/// over those braces, because a zero here would draw a badge reading nothing.
|
||
private var dragReplica: some View {
|
||
let count = max(1, selectedCount)
|
||
return ZStack {
|
||
if count > 2 { replicaFace.offset(x: 10, y: 10).opacity(0.45) }
|
||
if count > 1 { replicaFace.offset(x: 5, y: 5).opacity(0.7) }
|
||
replicaFace
|
||
}
|
||
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
|
||
.padding(BoardMetrics.replicaPadding(bodyPointSize: pointSize))
|
||
}
|
||
|
||
/// A static rendition of the face — a drag image is a snapshot, so it carries no gestures, no
|
||
/// editor and no geometry observers, and crucially no marquee registration (one built out of the
|
||
/// live face would re-register the card's frame from inside the preview's geometry and then
|
||
/// deregister it when the image went away, quietly stealing the card from the rubber band and the
|
||
/// arrow keys).
|
||
private var replicaFace: some View {
|
||
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
|
||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||
.foregroundStyle(iconTint)
|
||
.imageScale(.medium)
|
||
Text(card.title.value ?? "Untitled")
|
||
.boardFont(.body)
|
||
.lineLimit(4)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
attachmentsIndicator
|
||
}
|
||
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
||
.padding(.leading, stripeWidth)
|
||
// **The size of the face it was lifted from**, taken from that face's own measurement rather
|
||
// than from a representative figure: a card is as wide as its lane's interior column, so a
|
||
// replica drawn at a nominal width is visibly a different card from the one under the cursor,
|
||
// and — since the system centres a preview on the view the drag started from — leaves the
|
||
// pointer sitting beside the image instead of on it. The width is the only frame this needs:
|
||
// the replica lays the same row out with the same paddings and the same `lineLimit`, so at
|
||
// the face's width it comes out at the face's height (`BoardMetrics`).
|
||
.frame(
|
||
width: BoardMetrics.cardReplicaWidth(measured: measuredWidth, bodyPointSize: pointSize),
|
||
alignment: .leading
|
||
)
|
||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(BoardSurface.cardPlate))
|
||
.overlay(alignment: .leading) { accentStripe }
|
||
.dragReplicaShadow(zoom: zoom)
|
||
}
|
||
|
||
// MARK: - Context menus
|
||
|
||
/// Open, Rename, Style…, the quick-style recents row, Delete — 11-command-nexus.md ▸ Context
|
||
/// menus' Card row, in its order.
|
||
@ViewBuilder
|
||
private func boardMenu(openCard: @escaping (ItemID) -> Void) -> some View {
|
||
// Open: Board ▸ Open Card's pointer twin (`OpenCardCommand`), restricted to the clicked card
|
||
// alone — "a card window is tied to one card" (11-command-nexus.md), so unlike Style… and
|
||
// Delete below it, this row never widens to the selection; Open never opens multiple, even
|
||
// when the clicked card is part of one. It calls the very `openCard` closure the double-click
|
||
// gesture above uses, not `OpenCardCommand`'s mid-edit branches: there is no gesture path from
|
||
// a focused inline editor to *this* card's context menu, so there is nothing here to commit
|
||
// first — only the plain open.
|
||
Button("Open") {
|
||
openCard(card.id)
|
||
}
|
||
|
||
Divider()
|
||
|
||
// Rename: Board ▸ Rename's exact store path (`BoardRenameCommand`) — `beginRename(of:
|
||
// currentTitle:)`, seeded with the card's live title. The menu-bar item additionally requires
|
||
// this card to be the *sole* selection; a context menu already names its target by where it
|
||
// was invoked, so — standard macOS practice — it acts on the clicked card outright.
|
||
Button("Rename") { beginRename() }
|
||
.disabled(!store.acceptsBoardMutations)
|
||
|
||
// The target is **deferred**, and that is not a style choice: `.contextMenu`'s builder is
|
||
// non-escaping, so it runs while this body does, and computing a `StyleTarget` eagerly meant
|
||
// reading `store.selection` at body time — the O(board) subscription this view was rebuilt to
|
||
// shed (`isSelected`). Passed as a closure, `styleTarget` evaluates when a row *acts*, which
|
||
// is where every other menu target here is already read from (`deleteTargets`).
|
||
StyleMenuItems(store: store, recents: appModel.styleRecents, target: { styleTarget })
|
||
|
||
Divider()
|
||
|
||
// Delete: File ▸ Delete's exact store path (`store.delete`, `TrashCommands`'s twin), on the
|
||
// widened target set below (`targetIDs`) — the successor-selection rule is `delete(_:)`'s own,
|
||
// so this row gets it for free.
|
||
Button("Delete") { deleteTargets() }
|
||
.disabled(!store.acceptsBoardMutations)
|
||
}
|
||
|
||
/// `boardMenu`'s plain rows as VoiceOver custom actions — every one calling the *same* private
|
||
/// method its menu row does, so the two surfaces cannot come to mean different things.
|
||
@ViewBuilder
|
||
private func boardActions(openCard: @escaping (ItemID) -> Void) -> some View {
|
||
Button("Open") { openCard(card.id) }
|
||
Button("Rename") { beginRename() }
|
||
.disabled(!store.acceptsBoardMutations)
|
||
Button("Delete") { deleteTargets() }
|
||
.disabled(!store.acceptsBoardMutations)
|
||
}
|
||
|
||
/// Delete and Reveal in Finder — the two rows 11-command-nexus.md gives a trash card, and no
|
||
/// others ("Trash cards | Delete (permanent — 03's recoverability confirm), Reveal in Finder").
|
||
///
|
||
/// **Put Back is gone** with the tombstone model: restoring is drag-out or ⌘X/⌘V (03 § Trash).
|
||
/// Reveal in Finder is the odd one out and deliberately so: it is "not edit-shaped and stays
|
||
/// enabled on trash selections", read-only lock included — inspecting a folder before a purge is
|
||
/// exactly the errand it exists for.
|
||
///
|
||
/// The Delete row goes through the window's confirmation host rather than straight to the store,
|
||
/// because this delete is the **permanent** one and the alert is what stands between it and an
|
||
/// unrecoverable loss (03 § Trash; `TrashConfirmations.requestTrashDelete`).
|
||
@ViewBuilder
|
||
private func trashMenu(confirmations: TrashConfirmations) -> some View {
|
||
Button("Delete") { requestPurge(confirmations) }
|
||
.disabled(!store.acceptsBoardMutations)
|
||
|
||
Divider()
|
||
|
||
Button("Reveal in Finder") { revealInFinder() }
|
||
}
|
||
|
||
/// `trashMenu`'s rows as VoiceOver custom actions — `boardActions`' twin, two rows and no Open.
|
||
@ViewBuilder
|
||
private func trashActions(confirmations: TrashConfirmations) -> some View {
|
||
Button("Delete") { requestPurge(confirmations) }
|
||
.disabled(!store.acceptsBoardMutations)
|
||
Button("Reveal in Finder") { revealInFinder() }
|
||
}
|
||
|
||
// MARK: - The rows' bodies
|
||
|
||
/// Board ▸ Rename's store path, seeded with the card's live title.
|
||
private func beginRename() {
|
||
store.transient.beginRename(of: card.id, currentTitle: card.title.value)
|
||
}
|
||
|
||
/// File ▸ Delete's store path over the context-menu target set.
|
||
private func deleteTargets() {
|
||
store.delete(targetIDs)
|
||
}
|
||
|
||
/// The trash's **permanent** delete, through the window's confirmation host — never straight to
|
||
/// the store, because the alert is what stands between this row and an unrecoverable loss.
|
||
private func requestPurge(_ confirmations: TrashConfirmations) {
|
||
confirmations.requestTrashDelete(of: targetIDs, in: store)
|
||
}
|
||
|
||
private func revealInFinder() {
|
||
NSWorkspace.shared.activateFileViewerSelecting(targetFolders)
|
||
}
|
||
|
||
/// VO-Space's landing: the ⌘-click funnel, on this card, **in this face's container** — so a
|
||
/// trash card's toggle can no more mix with a board selection than a ⌘-click could.
|
||
private func toggleSelection() {
|
||
store.click(
|
||
SelectionTarget(id: card.id, kind: .card, container: role.container),
|
||
modifier: .command
|
||
)
|
||
}
|
||
|
||
/// What this card's menu acts on: the whole selection when this card is part of it, else this card
|
||
/// alone — standard macOS context-menu targeting, shared by Style… (`styleTarget`) and Delete
|
||
/// (`targetIDs`) alike. Right-clicking something outside the selection acts on what was clicked.
|
||
private var styleTarget: StyleTarget {
|
||
.items(targetIDs)
|
||
}
|
||
|
||
/// Delete's target set — the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
|
||
/// because `store.delete(_:)` takes one directly. Container-scoped, so a trash row's menu never
|
||
/// widens to a board selection and vice versa.
|
||
private var targetIDs: Set<ItemID> {
|
||
guard store.selection.container == role.container, store.selection.ids.contains(card.id) else {
|
||
return [card.id]
|
||
}
|
||
return store.selection.ids
|
||
}
|
||
|
||
/// The folders Reveal in Finder points at — resolved in this face's container, so a trash row
|
||
/// reveals `<root>/.trash/<uuid>` and never a lane path that no longer holds the card.
|
||
private var targetFolders: [URL] {
|
||
ItemPath.resolve(targetIDs, in: role.container, snapshot: store.snapshot)
|
||
.map { $0.folder(under: store.rootURL) }
|
||
}
|
||
|
||
// MARK: - Title row
|
||
|
||
private var titleRow: some View {
|
||
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
|
||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||
.foregroundStyle(iconTint)
|
||
.imageScale(.medium)
|
||
titleOrEditor
|
||
// The title takes the row's width so the indicator sits hard against the trailing
|
||
// edge — and so the rename field fills the same span the title occupied.
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
attachmentsIndicator
|
||
}
|
||
}
|
||
|
||
/// The title, or the rename editor when this card is the rename target. The four exits and their
|
||
/// store calls are 04-interactions.md ▸ Grammar's, stated once in `InlineTitleField`.
|
||
@ViewBuilder
|
||
private var titleOrEditor: some View {
|
||
if isRenaming {
|
||
InlineTitleField(
|
||
text: draft,
|
||
prompt: "Card title",
|
||
onCommit: { store.commitRename() },
|
||
onAbandon: { store.transient.discardRename() },
|
||
// **Click-away commits** — a rename's rule, and the deliberate opposite of the
|
||
// placeholder's (04-interactions.md ▸ Grammar: "focus loss = commit, matching
|
||
// the card window's title field").
|
||
onFocusLoss: { store.commitRename() },
|
||
onCommitAndOpen: {
|
||
let id = card.id
|
||
store.commitRename()
|
||
if case let .board(openCard) = role { openCard(id) }
|
||
}
|
||
)
|
||
.boardFont(.body)
|
||
} else {
|
||
Text(card.title.value ?? "Untitled")
|
||
.boardFont(.body)
|
||
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
|
||
.lineLimit(4)
|
||
}
|
||
}
|
||
|
||
/// `iconColor`'s tint, or the standard secondary one. Deliberately not `AnyShapeStyle(.primary)`
|
||
/// on the fallback path: an uncoloured card icon is chrome, and chrome is secondary — the tint
|
||
/// exists to make a *hand-coloured* icon stand out from its neighbours.
|
||
private var iconTint: AnyShapeStyle {
|
||
if let color = Palette.color(for: card.iconColor) {
|
||
AnyShapeStyle(color)
|
||
} else {
|
||
AnyShapeStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
/// The one face chip in scope — shown only when the card actually has files, and quiet enough
|
||
/// that the title still dominates (03-board-ui.md § Card face). The count goes to the
|
||
/// accessibility *value* rather than onto the face: it is useful to know, not to look at.
|
||
///
|
||
/// **Decorative, and hidden outright** (10-accessibility.md): "face icon and chips are
|
||
/// decorative — folded into the element, never separately focusable … the flattened element
|
||
/// carries the attachment count in its value". The flattening above would drop a label here
|
||
/// anyway; saying it explicitly is what keeps the chip inert in the replica too, which is drawn
|
||
/// outside the flattened face.
|
||
@ViewBuilder
|
||
private var attachmentsIndicator: some View {
|
||
if !card.attachments.isEmpty {
|
||
Image(systemName: "paperclip")
|
||
.boardFont(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.accessibilityHidden(true)
|
||
}
|
||
}
|
||
|
||
/// K1 · left edge stripe, painted with the resolved `background` — "a card's [colour paints] a
|
||
/// stripe along its left edge; the surfaces themselves keep the standard chrome, so coloured
|
||
/// title text never sits on a coloured fill" (03-board-ui.md § Styling ▸ Capabilities).
|
||
///
|
||
/// A value that resolves to nothing — a typo'd palette name, a malformed hex, a sequence where
|
||
/// a scalar belongs — draws **no stripe**, and the value stays on disk exactly as written.
|
||
/// A `Shape` rather than a sized rectangle so it takes the plate's full height whatever the
|
||
/// content does — a title wrapping across its full four lines included.
|
||
@ViewBuilder
|
||
private var accentStripe: some View {
|
||
if let color = Palette.color(for: card.background) {
|
||
UnevenRoundedRectangle(topLeadingRadius: cornerRadius, bottomLeadingRadius: cornerRadius)
|
||
.fill(color)
|
||
.frame(width: stripeWidth)
|
||
// Decoration only: the whole plate is one click target for selection.
|
||
.allowsHitTesting(false)
|
||
}
|
||
}
|
||
|
||
// MARK: - Selection and rename plumbing
|
||
|
||
/// What the plate's edge is painted with: the accent when this card is selected or a Finder file
|
||
/// drag is hovering it, a separator hairline under Increase Contrast, and nothing otherwise.
|
||
///
|
||
/// The three-way branch is the whole of "state is never colour-alone, and Increase Contrast
|
||
/// strengthens borders" meeting on one shape: the resting border is *chrome* (every card gets
|
||
/// one, so it says nothing), and the accent ring stays the only thing that means "this one".
|
||
private var plateStroke: AnyShapeStyle {
|
||
if isSelected || isFileHovered {
|
||
AnyShapeStyle(Color.accentColor)
|
||
} else if Accommodations.drawsRestingBorder(contrast: contrast) {
|
||
AnyShapeStyle(.separator)
|
||
} else {
|
||
AnyShapeStyle(.clear)
|
||
}
|
||
}
|
||
|
||
private var plateStrokeWidth: CGFloat {
|
||
guard isSelected || isFileHovered else {
|
||
return Accommodations.borderWidth(1, contrast: contrast)
|
||
}
|
||
return Accommodations.borderWidth(isFileHovered ? 2.5 : 1.5, contrast: contrast)
|
||
}
|
||
|
||
/// Whether an external Finder file drag is hovering **this** card — the attach highlight
|
||
/// (`DragSession.fileAttachTarget`). The face declares no drop target of its own: the hover is
|
||
/// resolved analytically inside the lane's delegate, which is what keeps single-target dispatch
|
||
/// to one implementation (`BoardDrops`).
|
||
///
|
||
/// **Never on the trash side**: "Finder file drops on trash cards are inert" (04-interactions.md
|
||
/// ▸ The trash), and the trash column's own delegate clears the file highlight rather than
|
||
/// proposing one — so this is a second, structural statement of the same rule.
|
||
private var isFileHovered: Bool {
|
||
role.container == .board && drops.session.fileAttachTarget(onBoardRooted: store.rootKey) == card.id
|
||
}
|
||
|
||
/// **Board-only** — "everything edit-shaped is disabled on trash selections … Rename"
|
||
/// (04-interactions.md ▸ The trash). No path opens a rename on a trashed card, and this makes a
|
||
/// stray one render nothing rather than putting a live field over a card that cannot be edited.
|
||
private var isRenaming: Bool {
|
||
role.container == .board && store.transient.renameEditor?.targetID == card.id
|
||
}
|
||
|
||
private var draft: Binding<String> {
|
||
Binding(
|
||
get: { store.transient.renameEditor?.draftTitle ?? "" },
|
||
set: { store.transient.updateRenameDraft($0) }
|
||
)
|
||
}
|
||
}
|