04's settled clauses were mostly shipped already — the create landing resolved through DropSlotMath.cardSlot with one nominal shadow per importable file — but a release on the lane header fell through to the card zones, which clamp inward, so a scrolled lane could propose behind the header stripe. FileDropZones now folds header, attach hit-test, and card-slot resolution into one pure seam asked in that order, the header answering topmost per the ruling; lane headers register their frames for it. FinderDrop.shadowCount names the floor-at-one rule. New tests pin the header boundary, a differential against cardSlot's own zones (same zones, not similar), and a store-level differential proving a file landing takes the very ranks a card move there takes. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
1555 lines
80 KiB
Swift
1555 lines
80 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 selection changes
|
||
/// only the face's styling — never its geometry. **A card has one presentation** (resettled
|
||
/// 2026-07-28, reversing the pathfinder's selection-keyed carousel): the masonry never reflows on
|
||
/// a click, and viewing an attachment's media is the card window's job, not the face's
|
||
/// (03-board-ui.md § Card face).
|
||
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 })
|
||
// **The header is the file drop's topmost position** (04-interactions.md ▸ Drag and drop,
|
||
// settled 2026-07-28: "a release on the lane header resolves to the topmost position —
|
||
// forgiving beats a dead stripe"). Its frame is registered rather than derived from the
|
||
// grid's because the grid is scroll-view *content*: scrolled down, its top edge climbs
|
||
// past the header and would stop being a boundary at all. The bar itself never moves.
|
||
.onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { frame in
|
||
drops.registry.update(header: frame, for: lane.id)
|
||
}
|
||
.onDisappear { drops.registry.removeHeader(lane.id) }
|
||
.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,
|
||
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.
|
||
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).
|
||
///
|
||
/// ### 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).
|
||
private struct CardFaceView: View {
|
||
|
||
let store: BoardStore
|
||
let card: Card
|
||
|
||
/// 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
|
||
|
||
let openCard: (ItemID) -> Void
|
||
|
||
/// The app-wide quick-style recents — see `LaneView`'s own note.
|
||
@Environment(AppModel.self) private var appModel
|
||
|
||
/// 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 {
|
||
titleRow
|
||
.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 — 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
|
||
|
||
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, and no selection stroke (the commit
|
||
/// re-selects the *lane*).
|
||
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, and no geometry
|
||
/// registration — 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()
|
||
}
|
||
}
|
||
}
|