03's sharpened settle rule: rendering the arrangement means rendering the card — at release the shadow swaps for the dropped card(s) drawn in place immediately, the appear never waiting for the echo reload. The committed hold now carries the landing (ids, payload titles, operation) and surfaces read one DropLanding seam: within-board moves draw the real faces at their proposed slots under the arriving card's own key, so the echo is an invisible content swap; cross-board card arrivals draw payload-titled faces keyed positionally, so the echo reads as an ordinary arrival. Cross-board lane arrivals deliberately keep their shadow until the echo — a lane's face is a whole column with no honest payload equivalent. The 1500 ms failed-write timeout is now seamed (injectable duration, extracted expire) and pinned by tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
1602 lines
82 KiB
Swift
1602 lines
82 KiB
Swift
import AppKit
|
||
import SwiftUI
|
||
|
||
// MARK: - LaneView
|
||
|
||
/// One lane: a title bar and a vertically scrolling masonry of cards (03-board-ui.md § Lane).
|
||
///
|
||
/// ### The title bar (this milestone's subject)
|
||
///
|
||
/// Leading SF Symbol from `icon` — lenient, an unknown name renders the `square.stack` default
|
||
/// (`ItemSymbol`) — then the title or its quiet "Untitled" placeholder, a quiet secondary
|
||
/// card-count badge, and a trailing quiet new-card button. **The whole bar is the drag surface**:
|
||
/// a click selects the lane — toggling off on a repeat, exactly as empty space does
|
||
/// (04-interactions.md § Selection, settled) — and movement begins a **system drag session**
|
||
/// carrying the lane (`DragSession`, DRAG-REORDER.md). The click-versus-drag split is the system's
|
||
/// own now: `.onTapGesture` and `.onDrag` coexist, so a hesitant click can never start a drag and a
|
||
/// drag can never also select. The one thing carved out of the drag region is the new-card button,
|
||
/// which sits in an overlay outside it.
|
||
///
|
||
/// ### The lane's one context menu
|
||
///
|
||
/// "The lane has one context menu (settled), invoked on the header or on lane empty space alike"
|
||
/// (03-board-ui.md § Lane), so both surfaces attach the *same* `laneMenu`. It carries Rename, Style…,
|
||
/// the quick-style recents row, the Width stepper and Delete — 11-command-nexus.md ▸ Context menus'
|
||
/// Lane row, in its order, complete as of m5.
|
||
///
|
||
/// ### The card face
|
||
///
|
||
/// `CardFaceView`, complete as of m5: the search filter narrows what the masonry lays out and what
|
||
/// the badge counts, the deferred cut's dim rides the face (`cutTreatment`), and the sole-selected
|
||
/// card expands its attachment carousel in place (`CardCarousel`).
|
||
struct LaneView: View {
|
||
|
||
let store: BoardStore
|
||
let lane: Lane
|
||
|
||
/// The app-wide quick-style recents (03-board-ui.md § Styling ▸ Controls — "never board data"),
|
||
/// read from the environment rather than threaded down the strip: the list belongs to the app,
|
||
/// not to this board, and every context menu in the window wants it.
|
||
@Environment(AppModel.self) private var appModel
|
||
|
||
/// Interior masonry columns — the lane's width units, or the resize session's snapped count
|
||
/// while this lane is being dragged. Passed in rather than read off `lane` so the live drag can
|
||
/// override it (see `BoardView.laneSlot`).
|
||
let columns: Int
|
||
|
||
/// This lane's resting slot width — the replica's width, so the image under the cursor is the
|
||
/// lane at its real on-screen size (03-board-ui.md § Motion: "a faithful, full-size replica").
|
||
let slotWidth: CGFloat
|
||
|
||
/// The board window's drop machinery: the app-wide session, the geometry registry this lane
|
||
/// registers its card grid into, and the shared retarget every hover and every autoscroll step
|
||
/// goes through (`BoardDropContext`).
|
||
let drops: BoardDropContext
|
||
|
||
/// The strip's rubber band: the lane's empty space is one of its three surfaces, and every card
|
||
/// face registers its frame into the same registry (`MarqueeControl`).
|
||
let marquee: MarqueeControl
|
||
|
||
/// Opens a card's window — ⌘↩'s second half (04-interactions.md ▸ Grammar, "commits and opens
|
||
/// the card window"). Supplied by the strip, which is supplied by the host: a lane has no
|
||
/// business knowing about `WindowGroup` keys.
|
||
let openCard: (ItemID) -> Void
|
||
|
||
/// Reduce Motion, for the card transition below (10-accessibility.md). Read from the environment
|
||
/// and handed to `Motion`, which owns what "reduced" means.
|
||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||
|
||
/// Spacing between cards, and between the interior columns.
|
||
private let cardSpacing: CGFloat = 8
|
||
|
||
/// The lane plate's corner radius — shared by the selection treatment and the accent band, whose
|
||
/// top corners round to exactly this so the band reads as the lane's own edge.
|
||
private let cornerRadius: CGFloat = 10
|
||
|
||
/// C7 · full-column top edge (03-board-ui.md § Styling ▸ Capabilities).
|
||
private let bandHeight: CGFloat = 5
|
||
|
||
/// The lane's drawn height, for the replica. Measured rather than derived, because a lane is as
|
||
/// tall as the strip gives it.
|
||
@State private var measuredHeight: CGFloat = 0
|
||
|
||
/// This lane's edge-autoscroll driver — one per lane, ticking only while a card session is in
|
||
/// flight (`DragAutoScroller`, DRAG-REORDER.md § Edge autoscroll).
|
||
@State private var autoScroller = DragAutoScroller()
|
||
|
||
var body: some View {
|
||
// `spacing: 0` and the padding moved inside: the accent band is **full-width** along the
|
||
// lane's top edge, so it must sit outside the content inset rather than in it.
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
accentBand
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
header
|
||
cardStack
|
||
}
|
||
.padding(6)
|
||
}
|
||
.background(selectionBackground)
|
||
.overlay(selectionStroke)
|
||
// The deferred cut's dim (04-interactions.md ▸ Clipboard) — on the whole lane, because a cut
|
||
// lane is cut cards and all.
|
||
.cutTreatment(of: lane.id, in: store)
|
||
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { measuredHeight = $0 }
|
||
// **This lane's drop target**, on the whole body. It accepts *every* session type and routes
|
||
// internally — card sessions against this lane's masonry zones, lane sessions forwarded to
|
||
// the strip's logic, external Finder file sessions against those same zones — because
|
||
// single-target dispatch has no fall-through (DRAG-REORDER.md).
|
||
.onDrop(of: boardDropTypes, delegate: LaneDropDelegate(context: drops, laneID: lane.id))
|
||
}
|
||
|
||
// MARK: - Header
|
||
|
||
private var header: some View {
|
||
headerContent
|
||
// The bar is the drag surface, so it must be hit-testable across its whole width —
|
||
// including the empty stretch between the badge and the button.
|
||
.contentShape(Rectangle())
|
||
// **The header toggles like empty space** (04-interactions.md § Selection, settled): "a
|
||
// click on the already-selected lane's header unselects, one lane-click behavior
|
||
// everywhere, so a full lane keeps a pointer path out of selection". Hence the same
|
||
// `togglesOnRepeat` the empty space passes — the two surfaces differ only in where they
|
||
// are. `.onTapGesture` beside `.onDrag` is the click-versus-drag split: the system holds
|
||
// the drag off until the pointer actually moves, so a click is never a drag.
|
||
.onTapGesture {
|
||
store.click(
|
||
SelectionTarget(id: lane.id, kind: .lane, side: .live),
|
||
modifier: .current,
|
||
togglesOnRepeat: true
|
||
)
|
||
}
|
||
.onDrag(startLaneDrag, preview: { dragReplica })
|
||
.overlay(alignment: .trailing) { newCardButton }
|
||
.contextMenu { laneMenu }
|
||
// The lane's half of the Style… popover. Anchored on the header because that is the
|
||
// lane's own furniture — `styleEditorPresentation` decides whether this lane is the
|
||
// session's presenting anchor at all.
|
||
.popover(isPresented: styleEditorPresentation(store, anchor: lane.id), arrowEdge: .bottom) {
|
||
StyleEditorPopover(store: store, recents: appModel.styleRecents)
|
||
}
|
||
}
|
||
|
||
/// The lane's colour as C7 — "a lane's color paints a full-width band along its top edge; the
|
||
/// surfaces themselves keep the standard chrome, so colored title text never sits on a colored
|
||
/// fill" (03-board-ui.md § Styling ▸ Capabilities, settled in the pathfinder's treatment
|
||
/// shootout).
|
||
///
|
||
/// A value that resolves to nothing paints **no band**, and the bytes stay on disk exactly as
|
||
/// written — the card stripe's rule, for its reason: there is no sensible default colour for
|
||
/// "the author meant something we can't read", and a wrong colour is worse than none.
|
||
@ViewBuilder
|
||
private var accentBand: some View {
|
||
if let color = Palette.color(for: lane.background) {
|
||
UnevenRoundedRectangle(topLeadingRadius: cornerRadius, topTrailingRadius: cornerRadius)
|
||
.fill(color)
|
||
.frame(height: bandHeight)
|
||
.frame(maxWidth: .infinity)
|
||
// Decoration only: the header below it owns the lane's click and drag.
|
||
.allowsHitTesting(false)
|
||
}
|
||
}
|
||
|
||
// MARK: - The lane's one context menu
|
||
|
||
/// Rename, Style…, the quick-style recents row, the Width control, Delete — 11-command-nexus.md ▸
|
||
/// Context menus' Lane row, in its order, complete as of m5.
|
||
@ViewBuilder
|
||
private var laneMenu: some View {
|
||
// Rename: Board ▸ Rename's exact store path (`BoardRenameCommand`) — `beginRename(of:
|
||
// currentTitle:)`, seeded with the lane's live title. The menu-bar item additionally requires
|
||
// this lane 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 lane outright.
|
||
Button("Rename") {
|
||
store.transient.beginRename(of: lane.id, currentTitle: lane.title.value)
|
||
}
|
||
.disabled(!store.acceptsBoardMutations)
|
||
|
||
Divider()
|
||
|
||
StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget)
|
||
|
||
Divider()
|
||
|
||
widthControl
|
||
|
||
Divider()
|
||
|
||
// Delete: File ▸ Delete's exact store path (`store.delete`), on the same widened target set
|
||
// Style… above reads (`targetIDs`, `styleTarget`'s `Set<ItemID>` sibling below) — the
|
||
// successor-selection rule is `delete(_:)`'s own, so this row gets it for free.
|
||
Button("Delete") {
|
||
store.delete(targetIDs)
|
||
}
|
||
.disabled(!store.acceptsBoardMutations)
|
||
}
|
||
|
||
/// The width stepper — "the header context menu's Width control (stepper, uncapped) is the
|
||
/// precise control … it never touches the window, it **re-divides** the existing width across the
|
||
/// new unit total" (03-board-ui.md § Lane). A +/− pair rather than a slider or a fixed 1×/2×/3×
|
||
/// list, because the control is uncapped in one direction and floored at one unit in the other.
|
||
///
|
||
/// **Single-lane by nature**, unlike the style entries above it: the design gives the batch to
|
||
/// the ⌥⌘→/⌥⌘← menu items and keeps the stepper on the lane whose menu is open.
|
||
private var widthControl: some View {
|
||
let units = LaneLayoutMath.displayUnits(of: lane)
|
||
return Section("Width — \(units)×") {
|
||
Button("Increase Width") {
|
||
store.setLaneWidth(lane.id, units: units + 1)
|
||
}
|
||
Button("Decrease Width") {
|
||
store.setLaneWidth(lane.id, units: units - 1)
|
||
}
|
||
// A one-unit lane cannot shrink (`width` is ≥ 1), and an item whose only outcome is a
|
||
// no-op reads better disabled than dead — `LaneWidthCommands`' rule, same floor.
|
||
.disabled(units <= 1)
|
||
}
|
||
.disabled(!store.acceptsBoardMutations)
|
||
}
|
||
|
||
/// What this lane's menu styles: the whole selection when this lane is part of it, else this lane
|
||
/// alone — standard macOS context-menu targeting (the pathfinder's `styleSelection`, kept).
|
||
/// Right-clicking something outside the selection acts on what was clicked.
|
||
private var styleTarget: StyleTarget {
|
||
guard store.selection.liveness == .live, store.selection.ids.contains(lane.id) else {
|
||
return .items([lane.id])
|
||
}
|
||
return .items(store.selection.ids)
|
||
}
|
||
|
||
/// Delete's target set — the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
|
||
/// because `store.delete(_:)` takes one directly (`TrashEntryRow.targetIDs`'s naming, reused here
|
||
/// on the live side).
|
||
private var targetIDs: Set<ItemID> {
|
||
guard store.selection.liveness == .live, store.selection.ids.contains(lane.id) else {
|
||
return [lane.id]
|
||
}
|
||
return store.selection.ids
|
||
}
|
||
|
||
private var headerContent: some View {
|
||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||
Image(systemName: ItemSymbol.name(lane.icon, fallback: ItemSymbol.lane))
|
||
.foregroundStyle(.secondary)
|
||
.imageScale(.medium)
|
||
headerTitle
|
||
countBadge
|
||
Spacer(minLength: 0)
|
||
}
|
||
// Reserves the button's width so a long title truncates before it collides, and keeps the
|
||
// button out of the gestured region.
|
||
.padding(.trailing, 22)
|
||
.padding(.horizontal, 4)
|
||
}
|
||
|
||
/// The title, or the rename editor when this lane is the one being renamed.
|
||
///
|
||
/// A lane's **only** rename path is Board ▸ Rename (04-interactions.md ▸ Selection: "the menu
|
||
/// item is a lane's only rename path, since Return on a lane creates a card"), so nothing in
|
||
/// this view opens the editor — it only renders one that is already open.
|
||
@ViewBuilder
|
||
private var headerTitle: some View {
|
||
if isRenaming {
|
||
InlineTitleField(
|
||
text: renameDraft,
|
||
prompt: "Lane name",
|
||
onCommit: { store.commitRename() },
|
||
onAbandon: { store.transient.discardRename() },
|
||
onFocusLoss: { store.commitRename() },
|
||
// A lane has no card window; ⌘↩ still commits, which is the half of the rule that
|
||
// applies (04 ▸ Grammar's carve-out is "commits the edit — placeholder or rename —
|
||
// and open[s] the card window", and only a card has one to open).
|
||
onCommitAndOpen: { store.commitRename() }
|
||
)
|
||
.font(.headline)
|
||
} else {
|
||
Text(lane.title.value ?? "Untitled")
|
||
.font(.headline)
|
||
.foregroundStyle(lane.title.value == nil ? .secondary : .primary)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
}
|
||
}
|
||
|
||
/// The card-count badge — quiet, secondary (03-board-ui.md § Lane).
|
||
///
|
||
/// **It counts exactly what the body renders**, because it reads the same `renderedCards` the
|
||
/// masonry iterates. That is deliberate rather than incidental: "The count reads the search
|
||
/// filter like every other surface — during a search it shows the visible count, not the
|
||
/// total", so when m5's search card narrows `renderedCards` to the filter's survivors the badge
|
||
/// follows by construction, with no second rule to keep in step.
|
||
private var countBadge: some View {
|
||
Text("\(renderedCards.count)")
|
||
.font(.caption)
|
||
.monospacedDigit()
|
||
.foregroundStyle(.secondary)
|
||
.padding(.horizontal, 6)
|
||
.padding(.vertical, 1)
|
||
.background(Capsule().fill(.quaternary))
|
||
}
|
||
|
||
/// The new-card button — a **pointer twin** of File ▸ New Card whose click *names its target*:
|
||
/// "the lane header's new-card button overrides [the ⌘N target] rule — the click names its
|
||
/// target lane, selection notwithstanding" (11-command-nexus.md ▸ Pointer grammar, settled), so
|
||
/// it passes this lane and no anchor rather than consulting `NewCardTarget`.
|
||
private var newCardButton: some View {
|
||
Button {
|
||
store.transient.beginPlaceholder(inLane: lane.id)
|
||
} label: {
|
||
Image(systemName: "plus")
|
||
.imageScale(.small)
|
||
.foregroundStyle(.secondary)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel("New card in \(lane.title.value ?? "Untitled")")
|
||
// Mutating, so the read-only lock disables it like every other write path
|
||
// (02-architecture.md § The lock's scope), and the focused-editor rule closes it while an
|
||
// inline editor is open (04 ▸ Grammar) — the pointer twin of a disabled menu item.
|
||
.disabled(store.isReadOnly || store.isEditingInline)
|
||
}
|
||
|
||
// MARK: - The lane drag
|
||
|
||
/// Begins the lane's system drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
||
///
|
||
/// **Dragging any member of a multi-selection drags the whole selection**, in board order —
|
||
/// which is the lane level's flatten order. A lane outside the selection drags alone, standard
|
||
/// macOS targeting.
|
||
///
|
||
/// Refused under the read-only lock and while an inline editor is focused, like every other
|
||
/// mutating gesture (02-architecture.md § The lock's scope; 04 ▸ Grammar's focused-editor rule).
|
||
/// A refusal is an item provider carrying nothing: no session begins, every drop target declines,
|
||
/// and the image snaps back.
|
||
private func startLaneDrag() -> NSItemProvider {
|
||
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
||
let selection = store.selection
|
||
let ids: Set<ItemID> = selection.liveness == .live
|
||
&& selection.ids.contains(lane.id)
|
||
&& selection.ids.count > 1
|
||
? selection.ids
|
||
: [lane.id]
|
||
|
||
let members = store.snapshot.lanes.filter { !$0.isDeleted && ids.contains($0.id) }
|
||
guard !members.isEmpty else { return NSItemProvider() }
|
||
|
||
let root = store.rootURL
|
||
let payload = DragPayload(
|
||
boardRoot: root,
|
||
kind: .lanes,
|
||
side: .live,
|
||
items: members.map {
|
||
DragPayload.Item(
|
||
id: $0.id.rawValue,
|
||
folder: root.appendingPathComponent($0.id.rawValue, isDirectory: true).path,
|
||
title: $0.title.value
|
||
)
|
||
}
|
||
)
|
||
drops.session.beginLanes(
|
||
members.map(\.id),
|
||
folders: payload.folders,
|
||
// What a cross-board arrival's overlay has to draw with (`DroppedItem`) — the payload's
|
||
// own titles, so the session and the pasteboard cannot disagree about what travelled.
|
||
titles: payload.items.map(\.title),
|
||
// The dragged items' own sizes, frozen at drag start — the one thing that is
|
||
// (03-board-ui.md § Motion).
|
||
units: members.map { LaneLayoutMath.displayUnits(of: $0) },
|
||
source: store
|
||
)
|
||
return payload.itemProvider()
|
||
}
|
||
|
||
/// The image travelling under the cursor: **a faithful, full-size replica of the whole lane**,
|
||
/// not the strip of title bar that was grabbed (03-board-ui.md § Motion), fanned with ghosts and
|
||
/// a count badge for a multi-drag.
|
||
///
|
||
/// A static rendition rather than a live `LaneView`: a drag image is a snapshot, so it carries no
|
||
/// scrolling, no gestures and no geometry observers, and the card list is capped because anything
|
||
/// past the lane's height is clipped anyway.
|
||
private var dragReplica: some View {
|
||
let count = max(1, draggedLaneCount)
|
||
return ZStack {
|
||
if count > 2 { replicaFace.offset(x: 12, y: 12).opacity(0.45) }
|
||
if count > 1 { replicaFace.offset(x: 6, y: 6).opacity(0.7) }
|
||
replicaFace
|
||
}
|
||
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
|
||
.padding(12)
|
||
}
|
||
|
||
private var draggedLaneCount: Int {
|
||
let selection = store.selection
|
||
guard selection.liveness == .live, selection.ids.contains(lane.id) else { return 1 }
|
||
return selection.ids.count
|
||
}
|
||
|
||
private var replicaFace: some View {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
accentBand
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
headerContent
|
||
VStack(alignment: .leading, spacing: cardSpacing) {
|
||
ForEach(renderedCards.prefix(12)) { card in
|
||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||
.foregroundStyle(.secondary)
|
||
.imageScale(.medium)
|
||
Text(card.title.value ?? "Untitled")
|
||
.font(.body)
|
||
.lineLimit(2)
|
||
Spacer(minLength: 0)
|
||
}
|
||
.padding(10)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(RoundedRectangle(cornerRadius: 8).fill(.background.secondary))
|
||
}
|
||
Spacer(minLength: 0)
|
||
}
|
||
}
|
||
.padding(6)
|
||
}
|
||
.frame(width: max(slotWidth, 80), height: max(measuredHeight, 120), alignment: .topLeading)
|
||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background))
|
||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||
}
|
||
|
||
// MARK: - Body
|
||
|
||
/// The card stack. Its empty space is a click target in its own right (04 ▸ Selection): one
|
||
/// click selects the lane or, when it is already the selection, clears it; a double click
|
||
/// creates a card at the bottom with its title editor focused.
|
||
///
|
||
/// **"Selection scrolls into view"** (04-interactions.md ▸ Grammar): the reader watches the
|
||
/// navigation head — the cursor the arrows move, not the whole selection — and scrolls only when
|
||
/// the head names a card *this* lane renders, so exactly one lane responds to any one press.
|
||
/// Deliberately unwrapped by `withAnimation`: 03-board-ui.md § Motion has selection follow
|
||
/// "whatever transaction is active rather than easing on its own".
|
||
private var cardStack: some View {
|
||
ScrollViewReader { proxy in
|
||
scrollableCards
|
||
.onChange(of: store.transient.selectionHead) { _, head in
|
||
guard let head, let card = renderedCards.first(where: { $0.id == head }) else { return }
|
||
proxy.scrollTo(LaneSlot.identity(of: card.id))
|
||
}
|
||
}
|
||
}
|
||
|
||
private var scrollableCards: some View {
|
||
ScrollView(.vertical) {
|
||
// Cards stay standard width whatever the lane spans: at a slot width of
|
||
// `units × standard + (units - 1) × gap`, `MasonryLayout` divides back into exactly
|
||
// `units` columns of `standard` (03-board-ui.md § Layout — full visibility).
|
||
MasonryLayout(columns: columns, spacing: cardSpacing) {
|
||
ForEach(slots) { slot in
|
||
Group {
|
||
switch slot {
|
||
case let .card(card):
|
||
CardFaceView(
|
||
store: store,
|
||
card: card,
|
||
laneID: lane.id,
|
||
marquee: marquee,
|
||
drops: drops,
|
||
openCard: openCard
|
||
)
|
||
case let .placeholder(phase):
|
||
NewCardStubView(store: store, phase: phase, openCard: openCard)
|
||
case let .shadow(_, height):
|
||
// One of the drag's N contiguous shadows, at the dragged card's frozen
|
||
// height — the run's real footprint, so the drop lands exactly here.
|
||
DragShadow(cornerRadius: 8)
|
||
.frame(height: height)
|
||
case let .dropped(face):
|
||
// The same run, one instant later: the release has settled and the
|
||
// dropped card is drawn where its shadow was (`DroppedCardFace`).
|
||
DroppedCardFace(card: face.card, title: face.title)
|
||
}
|
||
}
|
||
// "Appear/disappear is scale + fade (cards scale from ~0.8 …)"
|
||
// (03-board-ui.md § Motion), which is how a create, a delete, a Put Back and
|
||
// (m5) a search filter's leavers all reach the masonry. The placeholder wears it
|
||
// too: it is the card, one round trip early. Whether any of it *performs* is
|
||
// decided upstream — at the reload for the real cards (`Motion.reloadAnimates`),
|
||
// at the gesture for the placeholder, which touches no disk.
|
||
//
|
||
// **The create handoff deliberately never reaches it.** A committed placeholder
|
||
// is already keyed by the arriving card's identity (`LaneSlot`), so when the echo
|
||
// reload swaps the pseudo-card for the real one the `ForEach` element is neither
|
||
// inserted nor removed — only its content changes — and an appear/disappear
|
||
// transition has nothing to run on. That is the whole of "the handoff must read
|
||
// as one arrival" (02-architecture.md ▸ TransientBoardState ▸ overlays), and it
|
||
// holds identically under Reduce Motion: a transition that does not fire has no
|
||
// variant to choose between.
|
||
.transition(Motion.cardTransition(reduced: reduceMotion))
|
||
// The scroll target. `ForEach` already carries this identity, but `scrollTo`
|
||
// resolves against an explicit `.id`, and it goes outermost so the transition
|
||
// above stays inside the identified view rather than around it.
|
||
.id(slot.id)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||
// The drag's reflow-to-make-room inside the lane, keyed on **this lane's shadow run**
|
||
// and nothing broader (03-board-ui.md § Motion). `MasonryLayout` is a `Layout` over one
|
||
// `ForEach` precisely so the round-robin reshuffle animates as positional slides rather
|
||
// than as remove/insert blinks (DRAG-REORDER.md § The card masonry).
|
||
.animation(Motion.dragReflow(reduced: reduceMotion), value: shadowRun)
|
||
// Where this lane's card grid is drawn, in the window's global space — the analytic
|
||
// resting grid the drop model replays `MasonryPlacement` over. Registered rather than
|
||
// re-derived, so the zones and the drawn grid cannot disagree.
|
||
.onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { frame in
|
||
drops.registry.update(
|
||
LaneDropRegistry.Grid(frame: frame, columns: max(1, columns), spacing: cardSpacing),
|
||
for: lane.id
|
||
)
|
||
}
|
||
.onDisappear { drops.registry.removeGrid(lane.id) }
|
||
// The edge-autoscroll anchor, **inside** the scroll view's content so
|
||
// `enclosingScrollView` resolves (`DragAutoScrollAnchor`). Every scroll step re-resolves
|
||
// the proposal through the same shared retarget the drop delegate uses, because the
|
||
// cursor is stationary while the content moves under it.
|
||
.background {
|
||
DragAutoScrollAnchor(scroller: autoScroller) {
|
||
drops.retargetCards(inLane: lane.id)
|
||
}
|
||
}
|
||
.contentShape(Rectangle())
|
||
// Order matters: the two-tap recogniser must be attached first so a double click is not
|
||
// consumed as two singles.
|
||
.onTapGesture(count: 2) {
|
||
guard !store.isReadOnly, !store.isEditingInline else { return }
|
||
store.transient.beginPlaceholder(inLane: lane.id)
|
||
}
|
||
// "Single click selects the lane (click again to unselect)" — the toggle the header
|
||
// shares (04-interactions.md § Selection), and the modifier grammar on top of it.
|
||
.onTapGesture {
|
||
store.click(
|
||
SelectionTarget(id: lane.id, kind: .lane, side: .live),
|
||
modifier: .current,
|
||
togglesOnRepeat: true
|
||
)
|
||
}
|
||
// The rubber band's first surface — "click-drag rubber-bands across lanes". Simultaneous
|
||
// so the taps above stay instant; the band's own begin guard is what keeps a drag that
|
||
// started on a card face out of it (`MarqueeControl`).
|
||
.simultaneousGesture(marquee.gesture(side: .live))
|
||
// The same menu the header carries — "one menu, invoked on the header or lane empty
|
||
// space alike" (03-board-ui.md § Lane, settled).
|
||
.contextMenu { laneMenu }
|
||
}
|
||
// The autoscroll driver, **structurally terminated**: a `.task(id:)` keyed on whether a card
|
||
// session is in flight at all, so it is cancelled the moment the session ends — and
|
||
// `DragSession`'s watchdog guarantees that flag clears however the drag finished
|
||
// (DRAG-REORDER.md § Edge autoscroll). Within a session, a pointer outside this lane's
|
||
// engagement rect simply scrolls nothing.
|
||
.task(id: drops.session.isDraggingCards) {
|
||
guard drops.session.isDraggingCards else { return }
|
||
await autoScroller.run()
|
||
}
|
||
}
|
||
|
||
/// Where a card drag lands **in this lane** and what that slot draws — a run of shadows while the
|
||
/// drag is in flight, the dropped cards themselves once the release has settled
|
||
/// (`DragSession.cardLanding`). `nil` when the proposal is elsewhere.
|
||
private var cardLanding: DropLanding? {
|
||
drops.session.cardLanding(onBoardRooted: store.rootURL, laneID: lane.id)
|
||
}
|
||
|
||
/// The run's **geometry** — where it opens and what each of its slots is worth in height — or
|
||
/// `nil` when no proposal names this lane. The masonry's one make-room mechanism, and the
|
||
/// reflow's narrow animation key.
|
||
///
|
||
/// Two sessions feed it and they are mutually exclusive by construction (a file session never
|
||
/// arms `DragSession`, so `isActive` is false for exactly as long as one is in flight):
|
||
///
|
||
/// - **a card drag**, at the dragged cards' frozen heights — the run's real footprint, so the
|
||
/// drop lands exactly where the shadows are;
|
||
/// - **a Finder file drag**, at the nominal height, one shadow per file — the cards being
|
||
/// proposed do not exist yet, so there is no measured height to be faithful to.
|
||
///
|
||
/// **Computed identically on both sides of a release**, deliberately: the settle changes what
|
||
/// the run's slots *contain*, never where they are or how much room they take, so this value —
|
||
/// the animation key — does not move at the drop. That is what makes the un-hide instant
|
||
/// rendering rather than motion (03-board-ui.md § Motion), with no suppression flag anywhere.
|
||
private var shadowRun: ShadowRun? {
|
||
if let cardLanding {
|
||
return ShadowRun(position: cardLanding.index, heights: drops.session.cardHeights)
|
||
}
|
||
if let proposal = drops.session.fileLaneProposal(onBoardRooted: store.rootURL, laneID: lane.id) {
|
||
return ShadowRun(
|
||
position: proposal.index,
|
||
heights: Array(repeating: LaneDropRegistry.nominalCardHeight, count: proposal.count)
|
||
)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
/// What the masonry lays out: the rendered cards, the drag's N contiguous shadows at the
|
||
/// proposal, and the new-card placeholder when this lane is the one being created into.
|
||
///
|
||
/// The placeholder is inserted **at the position the card will actually take** — after its anchor
|
||
/// for ⌘N's "immediately after it", at the bottom otherwise — by asking the very function the
|
||
/// commit uses to compute the rank (`BoardStore.insertionIndex`). One answer, so the pseudo-card
|
||
/// cannot appear anywhere but where the real card lands. Both insertions are computed against
|
||
/// `renderedCards`, and the placeholder's is shifted past a shadow run that opened in front of
|
||
/// it, so neither displaces the other.
|
||
///
|
||
/// **The phase rides along**, because it is what keys the slot: a committed placeholder wears the
|
||
/// arriving card's identity so the handoff is one arrival rather than two (`LaneSlot`). The
|
||
/// position math above is untouched by that — the key changes, the index does not — so the
|
||
/// masonry cannot flinch at the moment of commit.
|
||
///
|
||
/// The drag's run is the same story told at the other end: its slots change from shadows to the
|
||
/// dropped cards at release, at the same position and the same count, so nothing in this function
|
||
/// moves when a drop settles (`runSlots`).
|
||
private var slots: [LaneSlot] {
|
||
var result = renderedCards.map(LaneSlot.card)
|
||
|
||
let run = shadowRun
|
||
let runPosition = run.map { min(max(0, $0.position), result.count) }
|
||
var placeholder: (position: Int, phase: NewCardPlaceholder.Phase)?
|
||
if let pending = store.transient.newCardPlaceholder, pending.laneID == lane.id {
|
||
let position = BoardStore.insertionIndex(after: pending.anchorCardID, among: renderedCards)
|
||
?? result.count
|
||
placeholder = (position, pending.phase)
|
||
}
|
||
|
||
let inserted = runSlots(run)
|
||
if let runPosition {
|
||
result.insert(contentsOf: inserted, at: runPosition)
|
||
}
|
||
if var placeholder {
|
||
if let runPosition, placeholder.position >= runPosition {
|
||
placeholder.position += inserted.count
|
||
}
|
||
result.insert(.placeholder(placeholder.phase), at: min(placeholder.position, result.count))
|
||
}
|
||
return result
|
||
}
|
||
|
||
/// What the run at the proposal is made of — **the settle, as one branch**.
|
||
///
|
||
/// While the drag is in flight it is N dashed outlines at the dragged cards' frozen heights. The
|
||
/// instant the release commits it is the cards themselves: "at release the shadow is replaced by
|
||
/// the dropped card(s) drawn in place immediately, the appear never waiting for the echo — a
|
||
/// lingering shadow over a hidden card is the hold failing its one job" (03-board-ui.md § Motion,
|
||
/// sharpened 2026-07-28).
|
||
///
|
||
/// Where each face's content comes from is `DropLanding.Dropped.isLocal`'s answer: a within-board
|
||
/// landing is a card this snapshot still has — at its pre-drop position, or in the trash for a
|
||
/// restore — so its **real** face travels to the landing slot, and a cross-board arrival has only
|
||
/// the title it travelled under until the echo brings the rest (`DroppedCardFace`).
|
||
private func runSlots(_ run: ShadowRun?) -> [LaneSlot] {
|
||
guard let run else { return [] }
|
||
guard case let .dropped(drop) = cardLanding?.run else {
|
||
return run.heights.enumerated().map { LaneSlot.shadow(index: $0.offset, height: $0.element) }
|
||
}
|
||
return drop.items.enumerated().map { index, item in
|
||
LaneSlot.dropped(DroppedFace(
|
||
index: index,
|
||
id: item.id,
|
||
card: drop.isLocal ? snapshotCard(item.id) : nil,
|
||
title: item.title,
|
||
keepsIdentity: drop.keepsIdentity
|
||
))
|
||
}
|
||
}
|
||
|
||
/// The dropped item as this board already knows it, tombstones included — a restore's card is in
|
||
/// the snapshot exactly as a moved one is, only on the other side of the live/trash boundary.
|
||
/// `nil` for an arrival this board has never held.
|
||
private func snapshotCard(_ id: ItemID) -> Card? {
|
||
for lane in store.snapshot.lanes {
|
||
if let card = lane.cards.first(where: { $0.id == id }) { return card }
|
||
}
|
||
return nil
|
||
}
|
||
|
||
/// **Tombstoned cards render nowhere**, and neither do the cards of a tombstoned lane — the
|
||
/// ancestor walk is absolute (01-storage-format.md § Deletion, 02-architecture.md's effective
|
||
/// liveness). The lane half of that rule is `BoardView`'s, which never builds a `LaneView` for a
|
||
/// tombstoned lane at all.
|
||
///
|
||
/// **A dragged card renders nowhere either, for as long as the drag is in flight.** It is lifted
|
||
/// out of the resting layout at pickup and stays out until release *whatever the effective
|
||
/// operation is* — a ⌥-copy's originals really do stay, but ⌥ can be pressed and released
|
||
/// mid-drag, and a layout that re-admitted them on every flip would flap the board under the
|
||
/// cursor (DRAG-REORDER.md § Resting-layout zones).
|
||
///
|
||
/// **At release the lift ends** (`DragSession.hiddenMembers`): a settled copy's originals are
|
||
/// back in this list in the same render pass the copies appear at the landing slot, and a settled
|
||
/// move's stay out because the overlay is now drawing them *there* rather than here (`runSlots`).
|
||
/// Either way nothing on this board is hidden behind a shadow once the mouse is up.
|
||
///
|
||
/// **A card the live search filter hides renders nowhere either** (04-interactions.md § Search):
|
||
/// "cards whose title *and* body both miss the query animate out". This is the one collection
|
||
/// that narrowing, which is what makes the filter "the single source of truth for what's on the
|
||
/// board" true of this lane's every surface at once — the masonry, the count badge (see
|
||
/// `countBadge`), the drop zones' resting layout, the marquee registration and the Finder
|
||
/// file-drop targets all read this list or the registry it populates, so none of them needs a
|
||
/// rule of its own.
|
||
private var renderedCards: [Card] {
|
||
let hidden = drops.session.hiddenMembers(onBoardRooted: store.rootURL)
|
||
let filter = store.searchFilter
|
||
return lane.cards.filter { !$0.isDeleted && !hidden.contains($0.id) && filter.matches($0) }
|
||
}
|
||
|
||
// MARK: - Selection
|
||
|
||
private var isSelected: Bool {
|
||
store.selection.liveness == .live && store.selection.ids.contains(lane.id)
|
||
}
|
||
|
||
/// The selection treatment: a subtle whole-lane accent wash and stroke. Deliberately quiet —
|
||
/// 03-board-ui.md gives lane *colour* to the top-edge accent band, so selection must not read as
|
||
/// a fill that would compete with it once that lands.
|
||
private var selectionBackground: some View {
|
||
RoundedRectangle(cornerRadius: 10)
|
||
.fill(isSelected ? AnyShapeStyle(Color.accentColor.opacity(0.08)) : AnyShapeStyle(.clear))
|
||
}
|
||
|
||
private var selectionStroke: some View {
|
||
RoundedRectangle(cornerRadius: 10)
|
||
.strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5)
|
||
}
|
||
|
||
// MARK: - Rename plumbing
|
||
|
||
private var isRenaming: Bool {
|
||
store.transient.renameEditor?.targetID == lane.id
|
||
}
|
||
|
||
/// The draft, as a binding onto transient state rather than as `@State`: the editor's text lives
|
||
/// in `TransientBoardState` because a reload has rules about it (the vanish discard), and a
|
||
/// second copy in the view would be the one the commit did not read.
|
||
private var renameDraft: Binding<String> {
|
||
Binding(
|
||
get: { store.transient.renameEditor?.draftTitle ?? "" },
|
||
set: { store.transient.updateRenameDraft($0) }
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: - Lane slots
|
||
|
||
/// The run of shadows a lane opens for whichever session is proposing into it — where it starts and
|
||
/// what each shadow is worth in height.
|
||
///
|
||
/// `Equatable` because it is the reflow's animation key: within a session the heights never change,
|
||
/// so the value moves exactly when the proposal does.
|
||
private struct ShadowRun: Equatable {
|
||
var position: Int
|
||
var heights: [CGFloat]
|
||
}
|
||
|
||
/// What a lane's masonry lays out — its cards, plus at most one pseudo-card.
|
||
///
|
||
/// The placeholder is not a `Card` and never will be: it has no disk presence and no UUID until its
|
||
/// title commits (02-architecture.md § Layering, the one named exception to the one-way flow).
|
||
/// Modelling it as a sibling case rather than as a fake `Card` is what keeps that true — nothing can
|
||
/// accidentally hand it to code expecting an item that exists.
|
||
///
|
||
/// ### The create handoff, which is entirely a question of `id`
|
||
///
|
||
/// 02-architecture.md ▸ TransientBoardState ▸ overlays (settled, 2026-07-28) requires the handoff to
|
||
/// **read as one arrival**: "the placeholder renders at the arriving card's exact geometry/chrome".
|
||
/// The mechanism is this enum's identity function and nothing else — no `matchedGeometryEffect`, no
|
||
/// second transaction, no suppression flag.
|
||
///
|
||
/// A placeholder carries its phase, and the phase decides its key:
|
||
///
|
||
/// - **`.editing`** keys to the constant `"placeholder"`. There is only ever one placeholder in one
|
||
/// lane at a time, and it must hold its identity — and therefore its keyboard focus — for as long
|
||
/// as the user types.
|
||
/// - **`.awaitingArrival(id)`** keys to `identity(of: id)`: **the very key the arriving card will
|
||
/// use**. The Writer's create has run, so the real card's UUID exists a full round trip before its
|
||
/// `Card` does, and adopting it early is what makes the handoff a *content swap inside one
|
||
/// `ForEach` element* rather than a removal and an insertion at coincident slots. The element
|
||
/// persists across the echo reload, so `Motion.cardTransition` — attached per slot in the masonry
|
||
/// — never fires on it: one arrival, one geometry, no double scale-and-fade.
|
||
///
|
||
/// The key therefore changes exactly once, at commit, and that change is deliberately outside every
|
||
/// animated transaction the board runs (`BoardStore.commitPlaceholder` is a plain synchronous call
|
||
/// from the editor's Return; `land`'s `withAnimation` comes a round trip later), so it costs no
|
||
/// motion either.
|
||
///
|
||
/// Two slots can never collide on the arriving key: `BoardStore.land` assigns the snapshot and
|
||
/// re-grounds the transient state — the discard-on-arrival among it — inside one transaction, so no
|
||
/// render pass ever sees both the real card and the placeholder standing in for it.
|
||
enum LaneSlot: Identifiable {
|
||
case card(Card)
|
||
/// The new-card placeholder, carrying the phase that decides both what it draws and what it is
|
||
/// keyed by. Passed down rather than re-read in the stub so the key and the face cannot disagree
|
||
/// about which half of the handoff this is.
|
||
case placeholder(NewCardPlaceholder.Phase)
|
||
/// One of a drag's N contiguous shadows, at the dragged card's frozen height.
|
||
case shadow(index: Int, height: CGFloat)
|
||
/// One of the **dropped** cards, drawn in its landing slot from the instant of release until the
|
||
/// echo reload brings the real one (`DroppedFace`).
|
||
case dropped(DroppedFace)
|
||
|
||
var id: String {
|
||
switch self {
|
||
case let .card(card): Self.identity(of: card.id)
|
||
case let .placeholder(phase): Self.identity(ofPlaceholderIn: phase)
|
||
// Constant per position in the run, so the shadows animate as slides when the proposal moves
|
||
// rather than blinking out and back in.
|
||
case let .shadow(index, _): "shadow:\(index)"
|
||
// **The placeholder's handoff, again.** A move keeps the identity it travelled under, so the
|
||
// slot wears the arriving card's own key and the echo reload swaps content inside one
|
||
// element — no removal, no insertion, no transition to fire. A copy mints a fresh GUID and a
|
||
// cross-board arrival may be reminted at the import boundary, so neither can promise a key:
|
||
// theirs is positional, and the real card's arrival reads as the arrival it is.
|
||
case let .dropped(face): face.keepsIdentity ? Self.identity(of: face.id) : "landing:\(face.index)"
|
||
}
|
||
}
|
||
|
||
/// A card slot's id, spelled once so the scroll-into-view call and the slot itself cannot
|
||
/// disagree about what `scrollTo` is looking for.
|
||
static func identity(of card: ItemID) -> String { "card:\(card.rawValue)" }
|
||
|
||
/// The key an open editor holds while it has no identity of its own to hold.
|
||
static let editingPlaceholderIdentity = "placeholder"
|
||
|
||
/// **The handoff, as one pure function.** See the type's doc comment: a committed placeholder
|
||
/// answers with the arriving card's key, which is what makes the swap continuous.
|
||
static func identity(ofPlaceholderIn phase: NewCardPlaceholder.Phase) -> String {
|
||
switch phase {
|
||
case .editing: editingPlaceholderIdentity
|
||
case let .awaitingArrival(id): identity(of: id)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One dropped card as its landing slot draws it, for the round trip between the release and the
|
||
/// echo (03-board-ui.md § Motion ▸ the drop settle).
|
||
///
|
||
/// `card` is the item as **this** board already holds it — a within-board landing, whose real face
|
||
/// simply moves to the landing slot. A cross-board arrival has none, and `title` is what it
|
||
/// travelled under (`DroppedItem`): enough for a face, and everything the destination can honestly
|
||
/// say before the write round-trips.
|
||
struct DroppedFace {
|
||
/// Position within the run — the positional key's whole content, for a landing that cannot
|
||
/// promise an identity.
|
||
var index: Int
|
||
var id: ItemID
|
||
var card: Card?
|
||
var title: String?
|
||
/// Whether the arriving card will wear `id`, and therefore whether this slot may key by it —
|
||
/// see `LaneSlot.id`.
|
||
var keepsIdentity: Bool
|
||
}
|
||
|
||
// MARK: - The card plate's metrics
|
||
|
||
/// The card plate's geometry, spelled once because **two views draw it**: the real face
|
||
/// (`CardFaceView`) and the placeholder standing in for a card on its way (`NewCardStubView`'s
|
||
/// `.awaitingArrival` face). 02-architecture.md ▸ TransientBoardState ▸ overlays makes the handoff
|
||
/// "read as one arrival — the placeholder renders at the arriving card's exact geometry/chrome", and
|
||
/// exact is only checkable if there is one set of numbers rather than two that happen to agree.
|
||
private enum CardFaceMetrics {
|
||
/// Shared by the plate, the accent stripe and the selection stroke, so the stripe reads as part
|
||
/// of the card's edge rather than a bar laid over it.
|
||
static let cornerRadius: CGFloat = 8
|
||
/// K1 · left edge stripe (03-board-ui.md § Styling ▸ Capabilities). Reserved as padding whether
|
||
/// or not a stripe paints, so colouring a card never shifts its title.
|
||
static let stripeWidth: CGFloat = 4
|
||
/// The plate's inset around its content.
|
||
static let contentPadding: CGFloat = 10
|
||
/// Between the icon, the title and the attachments chip — and between the title row and the
|
||
/// carousel below it.
|
||
static let rowSpacing: CGFloat = 6
|
||
}
|
||
|
||
// 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).
|
||
///
|
||
/// ### 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).
|
||
///
|
||
/// ### The attachment carousel
|
||
///
|
||
/// The face is a top-aligned `VStack` and its two decorations — the accent stripe and the selection
|
||
/// stroke — are shapes in overlays, so both stretch to whatever height the content takes. That is
|
||
/// what lets the carousel expand *inside* this card without any of it being re-derived: the masonry
|
||
/// already isolates column heights, so a taller card pushes only the cards below it in its own
|
||
/// column, and every consumer of a card's size reads a **live registered frame** rather than a
|
||
/// nominal one — the drop model's resting grid (`onGeometryChange` into `LaneDropRegistry`) and the
|
||
/// rubber band's sweep universe (`marqueeTarget`) both re-register the moment the card grows, so
|
||
/// neither needed a word about the expansion.
|
||
///
|
||
/// Who expands and when is `CardCarousel`'s, and it is the whole of what this view decides here:
|
||
/// `soleSelectedCardID` is the narrow animation key (03-board-ui.md § Motion), `expandedCardID` is
|
||
/// the same answer with the rubber band's suppression on top, and the difference between them is
|
||
/// why a band never animates anything.
|
||
private struct CardFaceView: View {
|
||
|
||
let store: BoardStore
|
||
let card: Card
|
||
|
||
/// The lane this face is drawn in — the middle component of the card's folder path, which is
|
||
/// what the carousel needs to reach `attachments/`. Passed rather than looked up: the lane
|
||
/// rendering this face already knows, and a walk of the snapshot per card face to re-learn it
|
||
/// would be the board's own layout asking the board where its cards are.
|
||
let laneID: ItemID
|
||
|
||
/// The strip's rubber band — the registry this face registers its drawn frame into, and the
|
||
/// session whose in-flight band suppresses the carousel (`CardCarousel.expanded`).
|
||
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
|
||
|
||
let openCard: (ItemID) -> Void
|
||
|
||
/// The app-wide quick-style recents — see `LaneView`'s own note.
|
||
@Environment(AppModel.self) private var appModel
|
||
|
||
/// Reduce Motion, for the carousel's expansion and its page slides (10-accessibility.md). Read
|
||
/// from the environment and handed to `Motion`, which owns what "reduced" means.
|
||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||
|
||
/// 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 `CardFaceMetrics` 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 { CardFaceMetrics.cornerRadius }
|
||
|
||
/// K1 · left edge stripe (03-board-ui.md § Styling ▸ Capabilities, settled in the pathfinder's
|
||
/// treatment shootout).
|
||
private var stripeWidth: CGFloat { CardFaceMetrics.stripeWidth }
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: CardFaceMetrics.rowSpacing) {
|
||
titleRow
|
||
carousel
|
||
}
|
||
// **The narrow transaction** (03-board-ui.md § Motion: "animated transactions are keyed
|
||
// narrowly — on the sole-selected card (carousel expansion) … never on broad state like the
|
||
// selection set"). The key is the sole selection and nothing else: a multi-select's churn
|
||
// never changes it, so multi-select churn never animates — the same bullet's other half,
|
||
// held by construction rather than by suppression.
|
||
//
|
||
// The band is deliberately *not* in the key, only in what renders — see `CardCarousel`.
|
||
.animation(Motion.carouselExpansion(reduced: reduceMotion), value: soleSelectedCardID)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(CardFaceMetrics.contentPadding)
|
||
// 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(.background.secondary))
|
||
.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.
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: cornerRadius)
|
||
.strokeBorder(
|
||
isSelected || isFileHovered ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
|
||
lineWidth: isFileHovered ? 2.5 : 1.5
|
||
)
|
||
)
|
||
// 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.
|
||
.cutTreatment(of: card.id, in: store)
|
||
.contentShape(Rectangle())
|
||
// **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.
|
||
.onTapGesture {
|
||
store.click(SelectionTarget(id: card.id, kind: .card, side: .live), modifier: .current)
|
||
}
|
||
// "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)
|
||
})
|
||
// **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(startCardDrag, preview: { dragReplica })
|
||
// The card's height, for 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`).
|
||
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in
|
||
drops.registry.update(height: height, for: card.id)
|
||
}
|
||
.onDisappear { drops.registry.removeHeight(card.id) }
|
||
.marqueeTarget(card.id, kind: .card, side: .live, in: marquee.registry)
|
||
.contextMenu { cardMenu }
|
||
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
|
||
StyleEditorPopover(store: store, recents: appModel.styleRecents)
|
||
}
|
||
}
|
||
|
||
// MARK: - The card drag
|
||
|
||
/// Begins the card's system drag session (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.liveCards`' 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 startCardDrag() -> NSItemProvider {
|
||
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
||
let snapshot = store.snapshot
|
||
let selection = store.selection
|
||
let ids: Set<ItemID> = selection.liveness == .live
|
||
&& selection.ids.contains(card.id)
|
||
&& selection.ids.count > 1
|
||
? selection.ids
|
||
: [card.id]
|
||
|
||
// 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 where !lane.isDeleted {
|
||
for member in lane.cards where !member.isDeleted && ids.contains(member.id) {
|
||
lanesByCard[member.id] = lane.id
|
||
titles[member.id] = member.title.value
|
||
}
|
||
}
|
||
let ordered = SelectionGrammar.liveCards(in: snapshot).filter { ids.contains($0) }
|
||
guard !ordered.isEmpty else { return NSItemProvider() }
|
||
|
||
let root = store.rootURL
|
||
let payload = DragPayload(
|
||
boardRoot: root,
|
||
kind: .cards,
|
||
side: .live,
|
||
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] ?? nil
|
||
)
|
||
}
|
||
)
|
||
drops.session.beginCards(
|
||
ordered,
|
||
folders: payload.folders,
|
||
// What a cross-board arrival's overlay has to draw with (`DroppedItem`) — the payload's
|
||
// own titles, so the session and the pasteboard cannot disagree about what travelled.
|
||
titles: payload.items.map(\.title),
|
||
// 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] ?? LaneDropRegistry.nominalCardHeight },
|
||
side: .live,
|
||
source: store
|
||
)
|
||
return payload.itemProvider()
|
||
}
|
||
|
||
/// 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).
|
||
private var dragReplica: some View {
|
||
let count = store.selection.liveness == .live && store.selection.ids.contains(card.id)
|
||
? max(1, store.selection.ids.count)
|
||
: 1
|
||
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(12)
|
||
}
|
||
|
||
/// A static rendition of the face — a drag image is a snapshot, so it carries no gestures, no
|
||
/// editor and no geometry observers.
|
||
private var replicaFace: some View {
|
||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||
.foregroundStyle(iconTint)
|
||
.imageScale(.medium)
|
||
Text(card.title.value ?? "Untitled")
|
||
.font(.body)
|
||
.lineLimit(4)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
attachmentsIndicator
|
||
}
|
||
.padding(10)
|
||
.padding(.leading, stripeWidth)
|
||
.frame(width: 220, alignment: .leading)
|
||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
|
||
.overlay(alignment: .leading) { accentStripe }
|
||
}
|
||
|
||
// MARK: - Context menu
|
||
|
||
/// Open, Rename, Style…, the quick-style recents row, Delete — 11-command-nexus.md ▸ Context
|
||
/// menus' Card row, in its order, complete as of m5.
|
||
@ViewBuilder
|
||
private var cardMenu: 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") {
|
||
store.transient.beginRename(of: card.id, currentTitle: card.title.value)
|
||
}
|
||
.disabled(!store.acceptsBoardMutations)
|
||
|
||
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") {
|
||
store.delete(targetIDs)
|
||
}
|
||
.disabled(!store.acceptsBoardMutations)
|
||
}
|
||
|
||
/// 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 {
|
||
guard store.selection.liveness == .live, store.selection.ids.contains(card.id) else {
|
||
return .items([card.id])
|
||
}
|
||
return .items(store.selection.ids)
|
||
}
|
||
|
||
/// Delete's target set — the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
|
||
/// because `store.delete(_:)` takes one directly (`TrashEntryRow.targetIDs`'s naming, reused here
|
||
/// on the live side).
|
||
private var targetIDs: Set<ItemID> {
|
||
guard store.selection.liveness == .live, store.selection.ids.contains(card.id) else {
|
||
return [card.id]
|
||
}
|
||
return store.selection.ids
|
||
}
|
||
|
||
// MARK: - Title row
|
||
|
||
private var titleRow: some View {
|
||
HStack(alignment: .firstTextBaseline, spacing: CardFaceMetrics.rowSpacing) {
|
||
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. Unchanged from the
|
||
/// stub this face replaces: 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()
|
||
openCard(id)
|
||
}
|
||
)
|
||
.font(.body)
|
||
} else {
|
||
Text(card.title.value ?? "Untitled")
|
||
.font(.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 label rather than onto the face: it is useful to know, not to look at.
|
||
@ViewBuilder
|
||
private var attachmentsIndicator: some View {
|
||
if !card.attachments.isEmpty {
|
||
Image(systemName: "paperclip")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.accessibilityLabel("\(card.attachments.count) attachments")
|
||
}
|
||
}
|
||
|
||
/// 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, m5's carousel expansion 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: - The attachment carousel
|
||
|
||
/// The paged carousel, when this card is the entire selection and has files to page through
|
||
/// (03-board-ui.md § Card face) — below the title, inside this same plate, adding height and
|
||
/// changing nothing above it.
|
||
///
|
||
/// **An untitled card gets it identically**: the placeholder is what sits above, and 03 makes
|
||
/// titles optional at every level without qualifying anything that reads them.
|
||
@ViewBuilder
|
||
private var carousel: some View {
|
||
if CardCarousel.expands(card, expanded: expandedCardID) {
|
||
AttachmentCarousel(
|
||
pages: CardCarousel.pages(of: card, boardRoot: store.rootURL, laneID: laneID)
|
||
)
|
||
.transition(Motion.carouselTransition)
|
||
}
|
||
}
|
||
|
||
/// **The animation key** — the sole-selected card, band or no band. See `CardCarousel`: keeping
|
||
/// the marquee out of the key is what makes a band's arrival and departure change the face
|
||
/// without easing anything, which is 03 § Motion's animation-free-by-construction rule for the
|
||
/// marquee applied to the one surface its churn could otherwise animate.
|
||
private var soleSelectedCardID: ItemID? {
|
||
CardCarousel.soleSelection(store.selection)
|
||
}
|
||
|
||
/// What actually expands: the key, suppressed for as long as a rubber band is in flight.
|
||
private var expandedCardID: ItemID? {
|
||
CardCarousel.expanded(store.selection, marqueeActive: marquee.session.isActive)
|
||
}
|
||
|
||
// MARK: - Selection and rename plumbing
|
||
|
||
private var isSelected: Bool {
|
||
store.selection.liveness == .live && store.selection.ids.contains(card.id)
|
||
}
|
||
|
||
/// 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`).
|
||
private var isFileHovered: Bool {
|
||
drops.session.fileAttachTarget(onBoardRooted: store.rootURL) == card.id
|
||
}
|
||
|
||
private var isRenaming: Bool {
|
||
store.transient.renameEditor?.targetID == card.id
|
||
}
|
||
|
||
private var draft: Binding<String> {
|
||
Binding(
|
||
get: { store.transient.renameEditor?.draftTitle ?? "" },
|
||
set: { store.transient.updateRenameDraft($0) }
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: - The new-card placeholder
|
||
|
||
/// The card being created, drawn as a pseudo-card in the masonry flow at standard card width —
|
||
/// 02-architecture.md § Layering's one named exception to the one-way flow, finally rendered.
|
||
///
|
||
/// Two faces, one per phase:
|
||
///
|
||
/// - **`.editing`** — a focused text field. Return commits, Escape abandons, and **click-away
|
||
/// discards**: the placeholder's rule, "the deliberate exception because nothing exists on disk
|
||
/// yet" (04-interactions.md ▸ Grammar).
|
||
/// - **`.awaitingArrival`** — the committed title as plain text, deliberately *not* an editor. The
|
||
/// Writer's create has run and the overlay is only covering the gap until the watcher round-trips
|
||
/// the real card; leaving a live field there would invite edits that have nowhere to go, and its
|
||
/// focus loss would fire the discard rule against a card that is already on its way.
|
||
///
|
||
/// ### The two faces are two *different* faces, on purpose
|
||
///
|
||
/// The editing face is an editor — the accent-stroked well the user is typing into. The awaiting
|
||
/// face is **a card**: the same plate, inset, stripe gutter, icon, font and title position a default
|
||
/// new card gets (`CardFaceView`, via the `CardFaceMetrics` both read). That is the second half of
|
||
/// 02-architecture.md ▸ TransientBoardState ▸ overlays' one-arrival rule — the first half is
|
||
/// `LaneSlot` keying a committed placeholder by the arriving card's identity, which makes the echo
|
||
/// reload a content swap inside one persistent element; drawing that content identically is what
|
||
/// makes the swap invisible rather than merely un-animated.
|
||
///
|
||
/// A newly created card is always default-styled — `BoardWriter.createCard` writes `schema`, `title`
|
||
/// and `order` and nothing else — so "the arriving card's chrome" is exactly: the level-default
|
||
/// symbol, the standard secondary tint, no accent stripe, no selection stroke (the commit re-selects
|
||
/// the *lane*), and no carousel (nothing is attached yet, and it is not the sole selection).
|
||
private struct NewCardStubView: View {
|
||
|
||
let store: BoardStore
|
||
|
||
/// How far along the birth is — handed down from the slot rather than re-read from the store, so
|
||
/// the face this view draws and the identity the slot is keyed by are the same answer.
|
||
let phase: NewCardPlaceholder.Phase
|
||
|
||
let openCard: (ItemID) -> Void
|
||
|
||
var body: some View {
|
||
switch phase {
|
||
case .editing: editor
|
||
case .awaitingArrival: arrivingFace
|
||
}
|
||
}
|
||
|
||
/// The editor well — an accent-stroked plate around the field, which is what a thing being typed
|
||
/// into should look like and deliberately not what a card looks like.
|
||
private var editor: some View {
|
||
InlineTitleField(
|
||
text: draft,
|
||
prompt: "Card title",
|
||
onCommit: { commit() },
|
||
onAbandon: { store.transient.discardPlaceholder() },
|
||
onFocusLoss: { store.transient.discardPlaceholder() },
|
||
onCommitAndOpen: {
|
||
// The one board command that stays enabled mid-edit: commit, then open
|
||
// (04 ▸ Grammar's carve-out). A commit that discarded — empty title, a
|
||
// vanished lane, a failed create — hands back no id and opens nothing.
|
||
if let id = commit() { openCard(id) }
|
||
}
|
||
)
|
||
.font(.body)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(CardFaceMetrics.contentPadding)
|
||
.background(RoundedRectangle(cornerRadius: CardFaceMetrics.cornerRadius).fill(.background.secondary))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: CardFaceMetrics.cornerRadius)
|
||
.strokeBorder(Color.accentColor.opacity(0.6), lineWidth: 1.5)
|
||
)
|
||
}
|
||
|
||
/// **The arriving card, drawn a round trip early.** Every line below has a counterpart in
|
||
/// `CardFaceView.body`/`titleRow`, and the numbers are the same numbers rather than equal ones
|
||
/// (`CardFaceMetrics`) — when the echo reload swaps this view for the real face inside the one
|
||
/// slot they share, nothing about the plate, the icon, the font or the title's position changes.
|
||
///
|
||
/// No stripe overlay and no selection stroke: both would be `.clear` for a default, unselected
|
||
/// new card, and a shape that paints nothing is better left unwritten than written and disabled.
|
||
private var arrivingFace: some View {
|
||
HStack(alignment: .firstTextBaseline, spacing: CardFaceMetrics.rowSpacing) {
|
||
Image(systemName: ItemSymbol.card)
|
||
.foregroundStyle(.secondary)
|
||
.imageScale(.medium)
|
||
Text(store.transient.newCardPlaceholder?.draftTitle ?? "")
|
||
.font(.body)
|
||
.lineLimit(4)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(CardFaceMetrics.contentPadding)
|
||
.padding(.leading, CardFaceMetrics.stripeWidth)
|
||
.background(RoundedRectangle(cornerRadius: CardFaceMetrics.cornerRadius).fill(.background.secondary))
|
||
}
|
||
|
||
/// Commits, then **re-selects the lane** — "Return commits and re-selects the lane (next Return
|
||
/// = next card)" (04-interactions.md ▸ Grammar). The lane rather than the new card is what makes
|
||
/// a run of Return-type-Return file a stack of cards without the user's hands leaving the
|
||
/// keyboard.
|
||
///
|
||
/// The lane is read before the commit, because every discard path clears the overlay that holds
|
||
/// it — and re-checked after, because one of those paths is *the lane vanished*, and selecting
|
||
/// something that renders nowhere would break the homogeneous-by-liveness invariant until the
|
||
/// next reload swept it away.
|
||
@discardableResult
|
||
private func commit() -> ItemID? {
|
||
let lane = store.transient.newCardPlaceholder?.laneID
|
||
let id = store.commitPlaceholder()
|
||
if let lane, store.snapshot.lanes.contains(where: { $0.id == lane && !$0.isDeleted }) {
|
||
store.select([lane], liveness: .live)
|
||
}
|
||
return id
|
||
}
|
||
|
||
private var draft: Binding<String> {
|
||
Binding(
|
||
get: { store.transient.newCardPlaceholder?.draftTitle ?? "" },
|
||
set: { store.transient.updateDraft($0) }
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: - The dropped card
|
||
|
||
/// **A dropped card, drawn at the instant of release** — 03-board-ui.md § Motion, sharpened
|
||
/// 2026-07-28: "at release the shadow is replaced by the dropped card(s) drawn in place immediately,
|
||
/// the appear never waiting for the echo (a lingering shadow over a hidden card is the hold failing
|
||
/// its one job)". The system drag image's fade then dissolves over a card that is already there,
|
||
/// which is the whole promise the settle makes.
|
||
///
|
||
/// **`NewCardStubView.arrivingFace`'s precedent, applied to the drag**, and for the same reason: a
|
||
/// static rendition at the same numbers (`CardFaceMetrics`) is what makes the echo's swap invisible
|
||
/// rather than merely un-animated. It carries no gestures, no drop target, no geometry registration
|
||
/// and no carousel — it stands in for exactly one round trip, and every surface that reads a card's
|
||
/// drawn frame (the drop zones, the rubber band) is reading the *snapshot*'s cards, which this is
|
||
/// not one of.
|
||
///
|
||
/// Two sources, one face (`DroppedFace`): a within-board landing draws the card the snapshot still
|
||
/// holds — icon, tint, stripe, attachments and all, so a move looks like the very card that was
|
||
/// picked up — and a cross-board arrival draws the title it travelled under under the level-default
|
||
/// symbol, because that is all the destination knows until the write lands.
|
||
private struct DroppedCardFace: View {
|
||
|
||
let card: Card?
|
||
let title: String?
|
||
|
||
var body: some View {
|
||
HStack(alignment: .firstTextBaseline, spacing: CardFaceMetrics.rowSpacing) {
|
||
Image(systemName: symbol)
|
||
.foregroundStyle(iconTint)
|
||
.imageScale(.medium)
|
||
Text(displayTitle ?? "Untitled")
|
||
.font(.body)
|
||
.foregroundStyle(displayTitle == nil ? .secondary : .primary)
|
||
.lineLimit(4)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
attachmentsIndicator
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(CardFaceMetrics.contentPadding)
|
||
.padding(.leading, CardFaceMetrics.stripeWidth)
|
||
.background(RoundedRectangle(cornerRadius: CardFaceMetrics.cornerRadius).fill(.background.secondary))
|
||
.overlay(alignment: .leading) { accentStripe }
|
||
// Inert, deliberately: the write naming this slot is already in flight, and a face that
|
||
// answered clicks would be offering to act on an item whose identity is a round trip away.
|
||
.allowsHitTesting(false)
|
||
.accessibilityHidden(true)
|
||
}
|
||
|
||
/// The card's own title, or the one it travelled under — `nil` means untitled either way, and
|
||
/// "Untitled" is a rendering rather than a value (03-board-ui.md § Card face).
|
||
private var displayTitle: String? { card?.title.value ?? title }
|
||
|
||
private var symbol: String {
|
||
guard let card else { return ItemSymbol.card }
|
||
return ItemSymbol.name(card.icon, fallback: ItemSymbol.card)
|
||
}
|
||
|
||
private var iconTint: AnyShapeStyle {
|
||
if let card, let color = Palette.color(for: card.iconColor) {
|
||
AnyShapeStyle(color)
|
||
} else {
|
||
AnyShapeStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var accentStripe: some View {
|
||
if let card, let color = Palette.color(for: card.background) {
|
||
UnevenRoundedRectangle(
|
||
topLeadingRadius: CardFaceMetrics.cornerRadius,
|
||
bottomLeadingRadius: CardFaceMetrics.cornerRadius
|
||
)
|
||
.fill(color)
|
||
.frame(width: CardFaceMetrics.stripeWidth)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var attachmentsIndicator: some View {
|
||
if let card, !card.attachments.isEmpty {
|
||
Image(systemName: "paperclip")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - The inline title field
|
||
|
||
/// The one text field all three inline editors wear — the new-card placeholder, a card rename, and
|
||
/// a lane rename — so the grammar around it is written once (04-interactions.md ▸ Grammar).
|
||
///
|
||
/// The four exits, and who differs on them:
|
||
///
|
||
/// | Exit | Placeholder | Rename |
|
||
/// |---|---|---|
|
||
/// | Return | commits | commits |
|
||
/// | Escape | discards | abandons |
|
||
/// | Click-away | **discards** | **commits** |
|
||
/// | ⌘↩ | commits + opens | commits + opens |
|
||
///
|
||
/// Only the click-away row differs, which is why it is a caller-supplied closure rather than a
|
||
/// branch in here: this view knows *that* focus left, never what that should mean.
|
||
///
|
||
/// **Every handler must be idempotent**, because the exits overlap by construction: Return commits
|
||
/// and then the field disappears, which also fires the focus-loss handler an instant later. The
|
||
/// store's `commitRename`/`commitPlaceholder` and the transient state's `discard…` all no-op against
|
||
/// an editor that is already closed, so the overlap costs nothing.
|
||
private struct InlineTitleField: View {
|
||
|
||
@Binding var text: String
|
||
let prompt: String
|
||
let onCommit: () -> Void
|
||
let onAbandon: () -> Void
|
||
let onFocusLoss: () -> Void
|
||
let onCommitAndOpen: () -> Void
|
||
|
||
@FocusState private var isFocused: Bool
|
||
|
||
var body: some View {
|
||
TextField(prompt, text: $text)
|
||
.textFieldStyle(.plain)
|
||
.lineLimit(1)
|
||
.focused($isFocused)
|
||
// The editor is born focused: every entry point to it is a deliberate "edit this now"
|
||
// (Return, ⌘N, the header button, a double click, Board ▸ Rename), and one that landed
|
||
// unfocused would need a second click to do anything.
|
||
.onAppear { isFocused = true }
|
||
.onSubmit(onCommit)
|
||
// ⌘↩ before the field sees the Return: the one board command enabled mid-edit
|
||
// (04 ▸ Grammar). Anything without the modifier is passed straight through, so plain
|
||
// Return still reaches `onSubmit`.
|
||
.onKeyPress(keys: [.return], phases: .down) { press in
|
||
guard press.modifiers.contains(.command) else { return .ignored }
|
||
onCommitAndOpen()
|
||
return .handled
|
||
}
|
||
// Escape reaches a focused text field as AppKit's cancel operation on some paths and as
|
||
// a plain key press on others; both are wired to the same idempotent abandon rather than
|
||
// guessing which one this control will get.
|
||
.onKeyPress(.escape) {
|
||
onAbandon()
|
||
return .handled
|
||
}
|
||
.onExitCommand(perform: onAbandon)
|
||
.onChange(of: isFocused) { _, focused in
|
||
guard !focused else { return }
|
||
onFocusLoss()
|
||
}
|
||
}
|
||
}
|