The settle rule 3f4125e introduced — at release the shadow swaps for the
dropped card(s) drawn in place immediately — is withdrawn on review. The
board returns to the presentation the committed-overlay hold has always
had: the shadows keep standing at their landing slot and the originals
keep their lift until the echo reload lands, whatever the operation was,
and the real faces arrive with the snapshot that carries the write.
Gone with it: the DropLanding/DroppedItem seam and the three landing
accessors, the hold's landing/operation payload and the removesOriginals
and keepsIdentity halves it decided, the pickup-captured titles a
cross-board arrival's face needed, LaneSlot.dropped with DroppedFace and
DroppedCardFace, the strip's dropped-lane run, and the trash column's
settled tombstone rows — the same presentation at the delete gesture's
end, which goes for the same reason while the drop-to-delete write and
its shadow row stay exactly as they were.
Kept: the hold itself, which predates the commit, along with the 1500 ms
failed-write timeout, its injectable duration and its extracted expire,
and the guard that keeps a settled release past retargeting. The tests
pinning those stay; the ones asserting what the landing slot drew are
gone, and the suite now pins the hold's own claim — the arrangement does
not move at the release.
The pause between the release and the echo is therefore an open design
question again, filed on the Redesign board.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
1417 lines
73 KiB
Swift
1417 lines
73 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. **A lane the query empties
|
||
/// shows `0` and keeps its slot** (04-interactions.md § Search, settled: "lanes are never
|
||
/// filtered out … the search filters cards, and the board's structure is not a search result") —
|
||
/// which is `BoardView.liveLanes` never consulting the filter at all, made visible here.
|
||
///
|
||
/// The rename exemption rides along for the same reason every other rule does: the badge counts
|
||
/// what the body renders, and while an inline rename is open its card is one of the things the
|
||
/// body renders (see `renderedCards`).
|
||
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,
|
||
// 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)
|
||
}
|
||
}
|
||
// "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 would land **in this lane**, or `nil` when the proposal is elsewhere.
|
||
private var cardProposal: Int? {
|
||
drops.session.laneProposal(onBoardRooted: store.rootURL, laneID: lane.id)
|
||
}
|
||
|
||
/// The shadow run this lane opens, or `nil` when no proposal names it — 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.
|
||
private var shadowRun: ShadowRun? {
|
||
if let position = cardProposal {
|
||
return ShadowRun(position: position, 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.
|
||
private var slots: [LaneSlot] {
|
||
var result = renderedCards.map(LaneSlot.card)
|
||
|
||
let run = shadowRun
|
||
let shadowPosition = 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 heights = run?.heights ?? []
|
||
if let shadowPosition {
|
||
let shadows = heights.enumerated().map { LaneSlot.shadow(index: $0.offset, height: $0.element) }
|
||
result.insert(contentsOf: shadows, at: shadowPosition)
|
||
}
|
||
if var placeholder {
|
||
if let shadowPosition, placeholder.position >= shadowPosition {
|
||
placeholder.position += heights.count
|
||
}
|
||
result.insert(.placeholder(placeholder.phase), at: min(placeholder.position, result.count))
|
||
}
|
||
return result
|
||
}
|
||
|
||
/// **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 session lasts.** 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). The copy's originals reappear when the write lands.
|
||
///
|
||
/// **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.
|
||
///
|
||
/// **With one exception, and it is the open inline rename** (04-interactions.md § Search,
|
||
/// settled): "an open inline rename survives the filter hiding its card — the editor is a surface
|
||
/// the filter doesn't reach; it stays open and focused, commits by UUID wherever the card lives,
|
||
/// Escape abandons". The editor is drawn *inside* its card's slot (`CardFaceView.isRenaming`), so
|
||
/// on this board a surface the filter doesn't reach means precisely a slot the filter doesn't
|
||
/// take away: filtering the card out would unmount the field mid-keystroke and silently drop what
|
||
/// the user had typed, which is the dirty-buffer courtesy read backwards. The exemption lasts
|
||
/// exactly as long as the editor — commit or Escape retires it, the predicate applies again in
|
||
/// the same pass, and a card that no longer matches animates out then (which is also the whole of
|
||
/// "rename deliberately gets no carve-out": the *query* still stands throughout).
|
||
///
|
||
/// It cannot arrive by typing, because focusing the search field is focus loss and commits the
|
||
/// rename first; the case it serves is a foreign edit that stops the card matching while the user
|
||
/// is renaming it.
|
||
private var renderedCards: [Card] {
|
||
Self.rendered(
|
||
lane.cards,
|
||
hiddenByDrag: drops.session.hiddenMembers(onBoardRooted: store.rootURL),
|
||
filter: store.searchFilter,
|
||
renaming: store.transient.renameEditor?.targetID
|
||
)
|
||
}
|
||
|
||
/// `renderedCards` as a pure function of its four inputs — see there for every rule it applies.
|
||
/// Split out only so the rules can be pinned without a view (`SearchFilterTests`); the lane's
|
||
/// masonry, its count badge and its drop zones all read the property, which reads this.
|
||
nonisolated static func rendered(
|
||
_ cards: [Card],
|
||
hiddenByDrag hidden: Set<ItemID>,
|
||
filter: SearchFilter,
|
||
renaming: ItemID?
|
||
) -> [Card] {
|
||
cards.filter { card in
|
||
guard !card.isDeleted, !hidden.contains(card.id) else { return false }
|
||
return filter.matches(card) || card.id == renaming
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
|
||
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)"
|
||
}
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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,
|
||
// 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 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()
|
||
}
|
||
}
|
||
}
|