A selection change re-ran every CardFaceView on the board (180 bodies ≈ 85 ms on the 6×30 fixture, 515 ≈ 233 ms on a real 515-card board, debug): the face's body read store.selection in three places — isSelected, the drag replica's count, and the context menu's styleTarget — and Observation invalidates every reader of the property, past the equatable gate entirely. The band overlay stayed cheap, which is why the marquee tracked the cursor while the highlight lagged ~0.4 s behind. Now LaneView and TrashLaneView hoist one selection read per body and hand each face isSelected/selectedCount as compared parameters; StyleMenuItems takes its target as a deferred closure; TrashLaneRowView gains the same treatment plus the Equatable gate it never needed before. Select-one-card: 180 bodies → 1. A growing band costs the selection's own running size; the real board's crossing fell 233 → 112 ms — the remainder is lane bodies re-measuring their masonry, a separate lane-level finding recorded in RENDER-INSTRUMENTATION.md. Also: select() gains defaultsSoleMember — the marquee's explicit nils never avoided the sole-member default, so a one-card band acquired a selectionHead and could scroll the lane out from under its own drag. MarqueeRenderCostTests pins the shape: redundant samples cost zero bodies, a growing band pays per crossing, and selectionStillRepaints holds a ≤8 budget.
1450 lines
84 KiB
Swift
1450 lines
84 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).
|
||
///
|
||
/// ### Equality gate
|
||
///
|
||
/// The lane is `Equatable` and instantiated through `.equatable()` (`BoardView.laneSlot`), because
|
||
/// the strip re-runs for reasons that have nothing to do with any one lane: `BoardView`'s body reads
|
||
/// the drag session, so **every drop-proposal change re-evaluates it**, and without a gate that
|
||
/// rebuilds every lane on the board — and, through them, every card face — on every cursor move
|
||
/// during a drag. See the `==` below for what the gate covers.
|
||
struct LaneView: View, Equatable {
|
||
|
||
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: @MainActor (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
|
||
|
||
/// Increase Contrast, for the selection stroke and the plate's resting edge below
|
||
/// (10-accessibility.md). Read from the environment and handed to `Accommodations`, which owns
|
||
/// what "increased" does to a stroke.
|
||
@Environment(\.colorSchemeContrast) private var contrast
|
||
|
||
/// Reduce Transparency, for the lane plate's wash (`Accommodations.lanePlateWash` — translucent
|
||
/// normally, the standard secondary background under the setting; the trash plate's rule).
|
||
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
|
||
|
||
/// The window's appearance — the input to 10-accessibility.md's runtime-contrast rule, and the
|
||
/// answer for every board whose background is not a hand-written hex (`headerInk`).
|
||
///
|
||
/// Read in `body` rather than resolved once, which is what makes "recomputed on appearance
|
||
/// change" free: a light/dark flip re-evaluates this view, and the decision below is taken again
|
||
/// against the colours of the appearance the window is now in.
|
||
@Environment(\.colorScheme) private var colorScheme
|
||
|
||
/// The board's ruler (03-board-ui.md ▸ Layout — zoom; `BoardZoom`), injected on the strip by
|
||
/// `BoardWindowHost`. An `@Environment` read rather than a value passed down deliberately: this
|
||
/// view is `.equatable()`, and environment values are the one input the gate cannot suppress.
|
||
@Environment(\.boardZoom) private var zoom
|
||
|
||
/// The live body metric — every figure this lane lays out on is a multiple of it
|
||
/// (`BoardMetrics`, 10-accessibility.md's full-relative-scaling rule), at the board's zoom.
|
||
private var pointSize: CGFloat { zoom.bodyPointSize }
|
||
|
||
/// Spacing between cards, and between the interior columns.
|
||
private var cardSpacing: CGFloat { BoardMetrics.cardSpacing(bodyPointSize: pointSize) }
|
||
|
||
/// 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 var cornerRadius: CGFloat { BoardMetrics.laneCornerRadius(bodyPointSize: pointSize) }
|
||
|
||
/// C7 · full-column top edge (03-board-ui.md § Styling ▸ Capabilities).
|
||
private var bandHeight: CGFloat { BoardMetrics.laneAccentBandHeight(bodyPointSize: pointSize) }
|
||
|
||
/// 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
|
||
|
||
/// The title bar's drawn height — **the replica's anchor** (`dragReplica`), measured for the same
|
||
/// reason the lane's height is: the bar is as tall as the text in it, at whatever size the system
|
||
/// is set to.
|
||
@State private var measuredHeaderHeight: CGFloat = 0
|
||
|
||
/// The card stack's viewport height — the masonry's height *floor* (`scrollableCards`).
|
||
/// Measured because a `ScrollView` proposes nothing along its scroll axis, so no frame maximum
|
||
/// can stretch the content to fill it; only an explicit minimum can.
|
||
@State private var scrollViewportHeight: 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()
|
||
|
||
/// The whole of what this lane is a function of **as far as the strip is concerned**: the lane
|
||
/// value (`Lane` is `Equatable`, its cards included, so an edit anywhere under this lane makes it
|
||
/// a different value), the two layout figures the strip resolves rather than the lane
|
||
/// (`columns` follows the live resize session, `slotWidth` the strip's standard width), and the
|
||
/// window-lived collaborators — the store by identity, the band and the drop machinery by their
|
||
/// own equivalence tests, which exist because `BoardView` rebuilds both structs, closures and
|
||
/// all, on every body pass.
|
||
///
|
||
/// **What the gate does not suppress is the point.** Everything this body reads through
|
||
/// Observation — `drops.session`'s proposal, members and file target, `store.selection`,
|
||
/// `store.searchFilter`, `store.transient`, `appModel.styleRecents` — invalidates this view
|
||
/// directly, and `.equatable()` has no say in that. So the lane a drag is actually over still
|
||
/// re-runs on every proposal change, and its `shadowRun` animation key still moves with it; what
|
||
/// stops is the *other* lanes re-running because the strip did.
|
||
///
|
||
/// Deliberately NOT compared: `@State` (per-identity, preserved across updates anyway),
|
||
/// `@Environment` values (SwiftUI invalidates on those itself), and `openCard` — a closure the
|
||
/// host rebuilds every pass, which is a pure hand-off to a `WindowGroup` key and identical in
|
||
/// behaviour whatever closure object carries it.
|
||
nonisolated static func == (lhs: LaneView, rhs: LaneView) -> Bool {
|
||
lhs.lane == rhs.lane
|
||
&& lhs.columns == rhs.columns
|
||
&& lhs.slotWidth == rhs.slotWidth
|
||
&& lhs.store === rhs.store
|
||
&& lhs.marquee.isEquivalent(to: rhs.marquee)
|
||
&& lhs.drops.isEquivalent(to: rhs.drops)
|
||
}
|
||
|
||
var body: some View {
|
||
// The strip's gate, observed (`BoardRenderMetrics`) — DEBUG only, and a `let _` because
|
||
// `body` is a `@ViewBuilder` and a bare `Void` call is not a view.
|
||
#if DEBUG
|
||
let _ = BoardRenderMetrics.countLaneBody()
|
||
#endif
|
||
// `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: BoardMetrics.laneStackSpacing(bodyPointSize: pointSize)) {
|
||
header
|
||
cardStack
|
||
}
|
||
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
|
||
}
|
||
.background(lanePlate)
|
||
.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))
|
||
// **The lane is an accessibility container** — "window → lanes (accessibility containers, in
|
||
// lane `order`) → cards (leaf elements, in card `order`)" (10-accessibility.md ▸ The board
|
||
// through VoiceOver). `.contain` rather than `.combine`: the header, the new-card button and
|
||
// every card must stay individually reachable, which is the whole point of a container the
|
||
// VoiceOver cursor enters (`TrashLaneView` states the same rule from the trash's side).
|
||
.accessibilityElement(children: .contain)
|
||
// "⟨title⟩, lane, N cards", where **N is the rendered count and therefore the filter's** —
|
||
// the very collection the visible badge counts, so the spoken count and the drawn one are
|
||
// one number ("the count reads the search filter like the visible badge"). A card the query
|
||
// hid is never built, so it leaves the masonry and the accessibility tree in the same pass,
|
||
// which is 10's "filtered-out cards leave layout and the accessibility tree together" holding
|
||
// by construction rather than by a second rule.
|
||
.accessibilityLabel(AccessibilityPhrases.laneLabel(title: lane.title.value, cards: renderedCards.count))
|
||
}
|
||
|
||
// MARK: - Header
|
||
|
||
private var header: some View {
|
||
headerContent
|
||
// **The lane title is a heading** — "lane titles are headings, so the headings rotor
|
||
// jumps lane-to-lane; on a one-dimensional board that *is* structural navigation"
|
||
// (10-accessibility.md ▸ Rotor). One flattened element rather than icon + text + badge:
|
||
// the glyph and the count are the container's information, already spoken by its label,
|
||
// and three stops where the design asks for a heading would make the rotor useless.
|
||
//
|
||
// `.contain` while a rename is open, because the flattening would otherwise swallow the
|
||
// text field the user is typing into — the one moment this subtree holds a control
|
||
// rather than chrome.
|
||
.accessibilityElement(children: isRenaming ? .contain : .ignore)
|
||
.accessibilityLabel(AccessibilityPhrases.displayTitle(lane.title.value))
|
||
.accessibilityAddTraits(headerTraits)
|
||
// **VO-Space toggles the lane's selection** — the ⌘-click analogue 10-accessibility.md
|
||
// gives a card, applied to the other selectable thing on the board, and routed through
|
||
// the same `BoardStore.click` funnel the pointer uses so the homogeneity and
|
||
// container rules are `SelectionGrammar`'s single answer rather than a second one.
|
||
// Deliberately **not** the header's own plain-click semantics: "moving the VO cursor
|
||
// never mutates selection … VO-Space on a card toggles its selection (the ⌘-click
|
||
// analogue — a toggle, never plain click's replace)", and a VO-Space that replaced would
|
||
// silently wipe a multi-lane selection the user had just built.
|
||
.accessibilityAction { toggleLaneSelection() }
|
||
// 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, container: .board),
|
||
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)
|
||
// The same measurement answers a second question, so it is read once: the bar is
|
||
// what a lane drag is grabbed by, and its height is where the replica has to hang
|
||
// from the pointer (`replicaHeaderCenterY`).
|
||
measuredHeaderHeight = frame.height
|
||
}
|
||
.onDisappear { drops.registry.removeHeader(lane.id) }
|
||
.overlay(alignment: .trailing) { newCardButton }
|
||
// **10-accessibility.md's ≥ 4.5:1 rule, at the one place on the board where text sits on
|
||
// a colour the user chose** (`BoardTextInk`) — palette name and hand-written hex alike,
|
||
// since the ink they need is the same question and only the *verification* differs
|
||
// (`ContrastMath`).
|
||
//
|
||
// A lane's plate is a *translucent wash* (`lanePlate`), so the title, the icon, the
|
||
// count badge and the rename field still land, effectively, on the board's `background`
|
||
// — the surface the design binds the threshold to ("text does sit on it",
|
||
// `BoardView.boardBackground`); the wash shifts it too little to change the answer. The
|
||
// card faces below are a different matter and deliberately untouched: they carry their
|
||
// own opaque plate (`BoardSurface.cardPlate`), so their titles never see the board
|
||
// colour.
|
||
//
|
||
// **Placed here, not at the end of the chain**, which is the modifier order doing real
|
||
// work: everything above — including the new-card button in the overlay — is text on the
|
||
// board background and takes the computed ink, while the context menu and the Style…
|
||
// popover attached below stay in the window's own appearance, because a menu is system
|
||
// chrome drawn on its own surface and not on this board's colour.
|
||
.boardTextInk(headerInk)
|
||
.contextMenu { laneMenu }
|
||
// **The context menu's plain rows, additionally as custom actions** — "where SwiftUI
|
||
// additionally surfaces menu items as custom accessibility actions, that's free
|
||
// improvement, not a separate design surface" (10-accessibility.md). The menu itself
|
||
// stays the inventory and is reachable the standard way (VO-⇧-M); this is the same four
|
||
// commands one rotor turn closer. Style… is deliberately absent: it opens a popover —
|
||
// its own accessible surface — and the quick-style swatch `Picker` beside it is not an
|
||
// action at all.
|
||
.accessibilityActions { laneActions }
|
||
// 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)
|
||
}
|
||
}
|
||
|
||
/// Which appearance's label vocabulary the header's text is drawn in — the window's own, unless
|
||
/// the **board** paints a background whose composited surface fails ≥ 4.5:1 against it
|
||
/// (10-accessibility.md ▸ Text scaling & visual accommodations; `BoardTextInk`).
|
||
///
|
||
/// It is not only the exotic case: ten of the twelve palette wells are dark colours, and every
|
||
/// one of them needs the dark appearance's label in a *light* window. A board styled entirely
|
||
/// from the in-app grid reaches this line as often as a hand-written one does.
|
||
///
|
||
/// It reads the *board*'s background and never this lane's, which is 03-board-ui.md ▸ Styling's
|
||
/// C7 ruling showing up as arithmetic: a lane's colour is an edge band, not a fill, so it is
|
||
/// never behind this text and carries no contrast obligation (`accentBand`).
|
||
private var headerInk: ColorScheme {
|
||
BoardTextInk.scheme(forBoardBackground: store.snapshot.background, appearance: colorScheme)
|
||
}
|
||
|
||
/// 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") { beginRename() }
|
||
.disabled(!store.acceptsBoardMutations)
|
||
|
||
Divider()
|
||
|
||
// Deferred, `CardFaceView`'s reason (`StyleMenuItems`): a non-escaping menu builder makes an
|
||
// eagerly computed target a body-time selection read.
|
||
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") { deleteTargets() }
|
||
.disabled(!store.acceptsBoardMutations)
|
||
}
|
||
|
||
/// The menu's plain rows again, as VoiceOver custom actions (see the `.accessibilityActions`
|
||
/// call site). Every one of them calls the *same* private method its menu row does, so the two
|
||
/// surfaces cannot drift into meaning different things — which is the only way "not a separate
|
||
/// design surface" is checkable rather than merely intended.
|
||
@ViewBuilder
|
||
private var laneActions: some View {
|
||
let units = LaneLayoutMath.displayUnits(of: lane)
|
||
Button("Rename") { beginRename() }
|
||
.disabled(!store.acceptsBoardMutations)
|
||
Button("Increase Width") { store.setLaneWidth(lane.id, units: units + 1) }
|
||
.disabled(!store.acceptsBoardMutations)
|
||
Button("Decrease Width") { store.setLaneWidth(lane.id, units: units - 1) }
|
||
.disabled(!store.acceptsBoardMutations || units <= 1)
|
||
Button("Delete") { deleteTargets() }
|
||
.disabled(!store.acceptsBoardMutations)
|
||
}
|
||
|
||
/// Board ▸ Rename's store path, seeded with the lane's live title — one method, two callers
|
||
/// (the context menu row and its accessibility twin).
|
||
private func beginRename() {
|
||
store.transient.beginRename(of: lane.id, currentTitle: lane.title.value)
|
||
}
|
||
|
||
/// File ▸ Delete's store path over the context-menu target set — the menu row's body and its
|
||
/// accessibility twin's alike.
|
||
private func deleteTargets() {
|
||
store.delete(targetIDs)
|
||
}
|
||
|
||
/// VO-Space's landing: the ⌘-click funnel, on this lane. `togglesOnRepeat` stays false because
|
||
/// only the *plain* branch reads it — the ⌘ branch is already a toggle, which is the point.
|
||
private func toggleLaneSelection() {
|
||
store.click(
|
||
SelectionTarget(id: lane.id, kind: .lane, container: .board),
|
||
modifier: .command
|
||
)
|
||
}
|
||
|
||
/// The header element's traits: a heading always, and **selected when the lane is** — "state is
|
||
/// never colour-alone: selection is a ring plus trait" (10-accessibility.md).
|
||
private var headerTraits: AccessibilityTraits {
|
||
isSelected ? [.isHeader, .isSelected] : [.isHeader]
|
||
}
|
||
|
||
/// 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.container == .board, 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 (the context-menu targeting rule, reused here
|
||
/// on the live side).
|
||
private var targetIDs: Set<ItemID> {
|
||
guard store.selection.container == .board, store.selection.ids.contains(lane.id) else {
|
||
return [lane.id]
|
||
}
|
||
return store.selection.ids
|
||
}
|
||
|
||
private var headerContent: some View {
|
||
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.laneHeaderSpacing(bodyPointSize: pointSize)) {
|
||
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. **Font-derived** rather than a fixed 22pt: the button
|
||
// is an `Image` at a relative image scale, so a fixed reserve would be overrun by the glyph
|
||
// itself at a large system text size and 03-board-ui.md's graceful-truncation rule would
|
||
// quietly stop holding (`BoardMetrics.newCardButtonReserve`).
|
||
.padding(.trailing, BoardMetrics.newCardButtonReserve(bodyPointSize: pointSize))
|
||
.padding(.horizontal, BoardMetrics.laneHeaderInset(bodyPointSize: pointSize))
|
||
}
|
||
|
||
/// 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() }
|
||
)
|
||
.boardFont(.headline)
|
||
} else {
|
||
Text(lane.title.value ?? "Untitled")
|
||
.boardFont(.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)")
|
||
.boardFont(.caption)
|
||
.monospacedDigit()
|
||
.foregroundStyle(.secondary)
|
||
.padding(.horizontal, BoardMetrics.badgeHorizontalPadding(bodyPointSize: pointSize))
|
||
.padding(.vertical, BoardMetrics.badgeVerticalPadding(bodyPointSize: pointSize))
|
||
.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)
|
||
// "The lane header's new-card button is a labeled child ('New card in ⟨lane⟩')"
|
||
// (10-accessibility.md ▸ The board through VoiceOver) — the header's one child element, which
|
||
// is why it lives in an overlay outside the flattened bar rather than inside it.
|
||
.accessibilityLabel(AccessibilityPhrases.newCardLabel(lane: lane.title.value))
|
||
// 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.container == .board
|
||
&& selection.ids.contains(lane.id)
|
||
&& selection.ids.count > 1
|
||
? selection.ids
|
||
: [lane.id]
|
||
|
||
let members = store.snapshot.lanes.filter { ids.contains($0.id) }
|
||
guard !members.isEmpty else { return NSItemProvider() }
|
||
|
||
let root = store.rootURL
|
||
let payload = DragPayload(
|
||
boardRoot: root,
|
||
kind: .lanes,
|
||
container: .board,
|
||
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,
|
||
// The strip's own-slot seed (`DragSession.begin`): the grabbed lane's position among
|
||
// the lanes with the dragged run removed — the strip proposal's own index space. A
|
||
// within-board lane drag never resolves to `.copy` (⌥ is ignored there), so unlike the
|
||
// card seed this one carries no modifier clause.
|
||
seed: DropTarget(
|
||
boardRoot: store.rootKey,
|
||
container: .strip,
|
||
index: store.snapshot.lanes.prefix { $0.id != lane.id }
|
||
.count { !ids.contains($0.id) }
|
||
)
|
||
)
|
||
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.
|
||
///
|
||
/// **The pointer keeps the point it grabbed** — the Finder-icon promise, and the whole of what
|
||
/// the anchoring padding below buys. SwiftUI centres a preview on the view the drag started
|
||
/// from, which here is the *title bar*: uncompensated, a full-height replica centred on a bar a
|
||
/// line and a half tall hangs half a lane above the cursor, and the cursor lands in the middle of
|
||
/// the image rather than on the bar it grabbed. Padding the replica so its own title bar is the
|
||
/// image's centre undoes exactly that, and lands every other pixel of the replica over the lane
|
||
/// it was lifted from (`DragPreviewAnchor`, which is where the arithmetic and its reasoning live).
|
||
private var dragReplica: some View {
|
||
let count = max(1, draggedLaneCount)
|
||
let anchor = DragPreviewAnchor.padding(length: replicaHeight, anchor: replicaHeaderCenterY)
|
||
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(BoardMetrics.replicaPadding(bodyPointSize: pointSize))
|
||
// Transparent, and only ever on one side — see `DragPreviewAnchor`, whose figure is
|
||
// deliberately invariant under the symmetric margin above, so the two paddings compose in
|
||
// any order.
|
||
.padding(.top, anchor.before)
|
||
.padding(.bottom, anchor.after)
|
||
// **Back to the window's own appearance**, undoing `header`'s runtime-contrast override for
|
||
// this one subtree (`boardTextInk`). The preview is attached inside that modifier and would
|
||
// otherwise inherit it — but the replica is not text on the board background: it draws its
|
||
// own opaque plate (`replicaFace`) and floats over whatever the cursor is above, which during
|
||
// a cross-window drag is another board entirely. The rule's premise is a user-chosen surface
|
||
// behind the glyphs, and here there is none.
|
||
.boardTextInk(colorScheme)
|
||
}
|
||
|
||
private var draggedLaneCount: Int {
|
||
let selection = store.selection
|
||
guard selection.container == .board, 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: BoardMetrics.laneStackSpacing(bodyPointSize: pointSize)) {
|
||
headerContent
|
||
VStack(alignment: .leading, spacing: cardSpacing) {
|
||
ForEach(renderedCards.prefix(12)) { card in
|
||
HStack(alignment: .firstTextBaseline,
|
||
spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
|
||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||
.foregroundStyle(.secondary)
|
||
.imageScale(.medium)
|
||
Text(card.title.value ?? "Untitled")
|
||
.boardFont(.body)
|
||
.lineLimit(2)
|
||
Spacer(minLength: 0)
|
||
}
|
||
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
|
||
.fill(BoardSurface.cardPlate)
|
||
)
|
||
}
|
||
Spacer(minLength: 0)
|
||
}
|
||
}
|
||
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
|
||
}
|
||
.frame(width: replicaWidth, height: replicaHeight, alignment: .topLeading)
|
||
// The lane's own wash over an opaque base — the replica floats over whatever the cursor is
|
||
// above, so unlike the resting lane it cannot let the wash composite against the board; the
|
||
// opaque `.background` behind it stands in for the window's neutral surface.
|
||
.background(lanePlate)
|
||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background))
|
||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||
.dragReplicaShadow(zoom: zoom)
|
||
}
|
||
|
||
/// The replica's size — **the lane's own**, floored for a lane that has not measured itself yet
|
||
/// (`BoardMetrics`). Named rather than inlined in the frame because the anchoring below has to
|
||
/// ask the same question the drawing does, and two derivations of one figure would be two
|
||
/// answers.
|
||
private var replicaWidth: CGFloat {
|
||
max(slotWidth, BoardMetrics.laneReplicaMinimumWidth(bodyPointSize: pointSize))
|
||
}
|
||
|
||
private var replicaHeight: CGFloat {
|
||
max(measuredHeight, BoardMetrics.laneReplicaMinimumHeight(bodyPointSize: pointSize))
|
||
}
|
||
|
||
/// Where the title bar sits inside the replica — its **centre**, measured down from the replica's
|
||
/// top edge, which is the point the pointer must hold (`dragReplica`).
|
||
///
|
||
/// The replica stacks exactly what the lane stacks, in the same order and off the same figures,
|
||
/// so this is the lane's own layout arithmetic rather than a second description of it: the accent
|
||
/// band when the lane's colour resolves to one (`accentBand` draws nothing when it does not, and
|
||
/// contributes no height either), the plate's inset, and half the measured bar.
|
||
private var replicaHeaderCenterY: CGFloat {
|
||
let band = Palette.color(for: lane.background) == nil ? 0 : bandHeight
|
||
return band + BoardMetrics.lanePlatePadding(bodyPointSize: pointSize) + measuredHeaderHeight / 2
|
||
}
|
||
|
||
// 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 {
|
||
// Evaluated ONCE per body, deliberately: the per-element `.accessibilitySortPriority`
|
||
// below reads `slots.count`, and reading the computed property from inside the `ForEach`
|
||
// closure re-runs the whole chain (`slots` → `renderedCards` → the O(n) card filter and
|
||
// the session's hidden-member resolution) once per element — O(n²) per lane body, which
|
||
// a drag pickup's synchronous whole-board layout multiplied into a visible stall.
|
||
let slots = self.slots
|
||
// **The board-side selection, read once for the whole lane.** This body is already subscribed
|
||
// to it — the header's own `isSelected` reads it — so hoisting the set here costs nothing new
|
||
// and lets every face take its selected-ness as a *compared parameter* instead of reading the
|
||
// store itself. That is the difference between a selection change re-running one body per
|
||
// lane and re-running one per card on the board (`CardFaceView.isSelected`,
|
||
// RENDER-INSTRUMENTATION.md ▸ Selection is O(board) in card bodies).
|
||
//
|
||
// Hoisted for `slots`' reason too: reading it inside the `ForEach` closure would re-derive
|
||
// the container branch and re-count the set once per element.
|
||
let selection = store.selection
|
||
let selectedIDs = selection.container == .board ? selection.ids : []
|
||
let selectedCount = max(1, selectedIDs.count)
|
||
return 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(Array(slots.enumerated()), id: \.element.id) { index, slot in
|
||
Group {
|
||
switch slot {
|
||
case let .card(card):
|
||
CardFaceView(
|
||
store: store,
|
||
card: card,
|
||
role: .board(openCard: openCard),
|
||
marquee: marquee,
|
||
drops: drops,
|
||
isSelected: selectedIDs.contains(card.id),
|
||
// 1 for an unselected face: the replica's fan and count badge want
|
||
// "how many ride along", and a card outside the selection drags
|
||
// alone (`CardFaceView.draggedIDs`).
|
||
selectedCount: selectedIDs.contains(card.id) ? selectedCount : 1
|
||
)
|
||
// **The value gate** (`CardFaceView.==`) — the lane's own, one level
|
||
// down: this body re-runs on every proposal change while a drag is over
|
||
// *this* lane, and the faces it draws are almost never what changed.
|
||
.equatable()
|
||
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: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
|
||
.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 restore 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))
|
||
// **VoiceOver reads the masonry by `order`, not by drawn position** —
|
||
// 10-accessibility.md ▸ Logical order, not masonry position (decided).
|
||
//
|
||
// The divergence narrowed when the masonry went column-major — walking down
|
||
// one column now *is* consecutive `order` — but it is still real, and it is
|
||
// why an explicit priority is needed at all: a geometry-sorted accessibility
|
||
// tree (which is what a container does without this) sweeps in reading order,
|
||
// left-to-right then down, which over a column-major grid interleaves the
|
||
// columns: 1, 4, 7, 2, 5, 8 …, an order that exists nowhere in the model, on
|
||
// disk, or in the keyboard grammar. Priority descends with the slot index, so
|
||
// the highest reads first and the list is exactly `slots` — the same sequence
|
||
// the masonry is handed and the same one `SelectionGrammar` flattens.
|
||
//
|
||
// The drag shadows are inert here: `DragShadow` hides itself from the tree, and
|
||
// a slot that is not an element consumes no priority.
|
||
.accessibilitySortPriority(Double(slots.count - index))
|
||
// 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)
|
||
}
|
||
}
|
||
// **The measured viewport is the masonry's height floor.** A `ScrollView` proposes
|
||
// nothing along its scroll axis, so `maxHeight: .infinity` cannot stretch this view —
|
||
// sized to fit its cards, it left the blank space beneath them (the whole body, in an
|
||
// empty lane) outside the content shape below, and every surface attached to it dead
|
||
// there: the lane-select tap, the double-click create, the context menu, and the band.
|
||
// The explicit minimum is what makes "click-drag rubber-bands across lanes" arm from a
|
||
// lane's own empty space (04-interactions.md § Selection; `TrashLaneView` is the twin).
|
||
.frame(maxWidth: .infinity, minHeight: scrollViewportHeight, 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 empty-space click surfaces live on a background layer, not on the container**
|
||
// (the 2026-08-06 click-latency fix). The pathfinder shape — `.onTapGesture(count: 2)`
|
||
// stacked over `.onTapGesture` on the container that wraps the masonry — made SwiftUI
|
||
// hold *every* single click in the lane, its own empty space and every card face
|
||
// alike, hostage to the system double-click interval while the sequential two-tap
|
||
// recogniser disambiguated (measured ~475 ms click-to-selection on a hosted board;
|
||
// `PointerLatencyTests` pins the recovery).
|
||
//
|
||
// The background layer is the structural answer to both halves of that defect. A card
|
||
// click hit-tests to the card, and the card is **not** a descendant of this layer, so
|
||
// no recogniser here can ever hold a card's click — where a container gesture always
|
||
// shares the card's gesture path, an ancestor's simultaneity notwithstanding. And a
|
||
// click that does land here *is* empty space by construction — the double-click create
|
||
// needs no geometric guard against the cards, the way the rubber band's begin does
|
||
// (`MarqueeControl`), because the masonry above already consumed everything that was
|
||
// not empty.
|
||
.background {
|
||
Rectangle()
|
||
.fill(.clear)
|
||
.contentShape(Rectangle())
|
||
// **No drag source on this layer, deliberately** (measured on real events,
|
||
// 2026-08-07): an `.onDrag` here — even one whose provider is empty — claims
|
||
// the mouse-drag the moment the cursor crosses the drag threshold, and the
|
||
// rubber band's simultaneous `DragGesture` on the container gets one sample
|
||
// and then silence: the band begins and freezes, and even the mouseUp never
|
||
// reaches the app. Dragless, the same sweep tracks every sample. And the drag
|
||
// source buys nothing in return: a bare count-1 tap on this layer fires in
|
||
// ~1–3 ms in a real event stream, identical to the with-`.onDrag` shape — the
|
||
// hold that made an empty provider look load-bearing (2026-08-06, "a lone
|
||
// click never fired") was the hosted harness's sterile `NSApp.postEvent`
|
||
// stream, which over-holds; real streams have nothing to disambiguate here.
|
||
// So: drags from empty space belong wholly to the band (`MarqueeControl`),
|
||
// whose begin guard keeps card-face drags out by geometry.
|
||
// **One recogniser, both meanings** — a single `.onTapGesture` that branches
|
||
// on `PointerClick.count`, AppKit's own `mouseDown` idiom. Not a second
|
||
// two-tap recogniser in *either* form: sequential stacking is the bug this
|
||
// fix removes, and a *multi-click* recogniser — sequential or simultaneous —
|
||
// is what makes macOS hold a dragless subtree's primary clicks for the whole
|
||
// double-click interval (see `PointerClick`; the count-1 tap alone triggers
|
||
// no such hold).
|
||
// A lone tap fires once; a double fires it once per click, so the branch is
|
||
// Finder's cadence exactly: the first click selects, the second creates.
|
||
//
|
||
// "Single click selects the lane (click again to unselect)" — the toggle the
|
||
// header shares (04-interactions.md § Selection), with the modifier grammar
|
||
// on top. "Double click creates a card at the bottom" — **plain only**: ⌘ and
|
||
// ⇧ double-clicks are selection gestures that happened twice, the card face's
|
||
// settled reading, so their second click re-enters the grammar instead of
|
||
// creating.
|
||
.onTapGesture {
|
||
let modifier = ClickModifier.current
|
||
if modifier == .plain, PointerClick.count > 1 {
|
||
// The second click of a plain double: the create. Never also the
|
||
// toggle — it would unselect the lane the first click just selected,
|
||
// under the placeholder this opens. A third click of a triple lands
|
||
// here too and no-ops on `isEditingInline`: the placeholder is open.
|
||
guard !store.isReadOnly, !store.isEditingInline else { return }
|
||
store.transient.beginPlaceholder(inLane: lane.id)
|
||
return
|
||
}
|
||
store.click(
|
||
SelectionTarget(id: lane.id, kind: .lane, container: .board),
|
||
modifier: modifier,
|
||
togglesOnRepeat: true
|
||
)
|
||
}
|
||
}
|
||
// 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.
|
||
//
|
||
// **Behind the click layer above** (a later `.background` stacks further back): the
|
||
// anchor is a plain `NSView`, hit-testable by default, and in front of the click layer
|
||
// it would swallow every empty-space click before the layer's recognisers saw one. It
|
||
// needs no hits itself — it exists to sit in the hierarchy and resolve its enclosing
|
||
// scroll view.
|
||
.background {
|
||
DragAutoScrollAnchor(scroller: autoScroller) {
|
||
drops.retargetCards(inLane: lane.id)
|
||
}
|
||
}
|
||
// The rubber band's first surface — "click-drag rubber-bands across lanes". On the
|
||
// container, not the layer above: ancestry means a drag over cards and empty space
|
||
// alike reaches it, and the band's own begin guard is what keeps a drag that started
|
||
// on a card face out of it (`MarqueeControl`). Simultaneous, so the taps stay instant.
|
||
.simultaneousGesture(marquee.gesture(in: .board))
|
||
// 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 }
|
||
// The engagement rect's reaches are distances to this lane's own furniture, so they are
|
||
// measured on the board's ruler (03-board-ui.md ▸ Layout — zoom). Read once at the head
|
||
// of the drag, which is once per level: zoom is inert while a session is in flight.
|
||
autoScroller.bodyPointSize = pointSize
|
||
await autoScroller.run()
|
||
}
|
||
// The floor's measurement — the scroll view's own height, which is exactly the space the
|
||
// masonry must cover for the empty-surface gestures above. No feedback loop: the lane's
|
||
// height is the strip's to give, so the content growing to the floor never moves the floor.
|
||
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { scrollViewportHeight = $0 }
|
||
}
|
||
|
||
/// 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.rootKey, 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.rootKey, laneID: lane.id) {
|
||
return ShadowRun(
|
||
position: proposal.index,
|
||
heights: Array(repeating: drops.registry.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
|
||
}
|
||
|
||
/// **A deleted card renders nowhere, and needs no rule to**: deletion is a *move* into `.trash/`
|
||
/// (03-board-ui.md § Trash, resettled 2026-07-28), so a deleted card has physically left
|
||
/// `lane.cards` and the trash column renders it instead. The tombstone era's ancestor walk and
|
||
/// effective-liveness predicate are retired with the flag they read.
|
||
///
|
||
/// **A card being dragged *as a move* renders nowhere, for as long as the session lasts.** It is
|
||
/// lifted out of the resting layout at pickup and stays out until release. A card being dragged
|
||
/// as a **copy** stays exactly where it is and dims (`CardFaceView`), because that is what the
|
||
/// copy will leave behind — so flipping ⌥ or ⌘ mid-drag reflows this lane once, which is the
|
||
/// feedback the modifier is asking for (DRAG-REORDER.md § Resting-layout zones, ruled
|
||
/// 2026-08-01). Either way the drop's index is counted in the layout on screen, which is why
|
||
/// `copyCards` and `moveCards` take their index in different spaces.
|
||
///
|
||
/// **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.rootKey),
|
||
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 !hidden.contains(card.id) else { return false }
|
||
return filter.matches(card) || card.id == renaming
|
||
}
|
||
}
|
||
|
||
// MARK: - Selection
|
||
|
||
private var isSelected: Bool {
|
||
store.selection.container == .board && store.selection.ids.contains(lane.id)
|
||
}
|
||
|
||
/// The lane's resting plate — the standard chrome's middle step (03-board-ui.md § Styling ▸
|
||
/// Capabilities: window, washed lane, opaque card), the same quaternary wash the trash column
|
||
/// wears (`Accommodations.lanePlateWash`). Translucent deliberately: a board-chosen colour
|
||
/// shows through, so the header ink computed against the board's background (`headerInk`)
|
||
/// keeps its premise on a painted board.
|
||
private var lanePlate: some View {
|
||
RoundedRectangle(cornerRadius: cornerRadius)
|
||
.fill(Accommodations.lanePlateWash(reduceTransparency: reduceTransparency).style)
|
||
}
|
||
|
||
/// The selection treatment: a hairline accent edge with a soft accent glow bleeding outward —
|
||
/// the system focus ring's vocabulary at selection strength (resettled 2026-08-06, replacing
|
||
/// the whole-lane wash-and-solid-ring, whose emphasis scaled with the lane's area: what read as
|
||
/// a ring on a card read as a slab on a column). No wash at all now — nothing tints the cards'
|
||
/// backdrop, and lane *colour* stays the accent band's alone (03-board-ui.md § Styling).
|
||
///
|
||
/// The glow rides the stroke shape, never the plate: the plate is translucent
|
||
/// (`Accommodations.lanePlateWash`), and a shadow drawn behind it would bleed through as murk.
|
||
/// Under Increase Contrast the halo yields to the solid full-alpha ring — the two width bases
|
||
/// below — so the setting's selection indicator is exactly what it was before the resettlement
|
||
/// (`Accommodations.drawsSelectionHalo`; the trash's lane rows and `CardFaceView` are
|
||
/// card-scale and keep the solid ring at every contrast: one selection vocabulary, intensity
|
||
/// inverse to area).
|
||
private var selectionStroke: some View {
|
||
RoundedRectangle(cornerRadius: cornerRadius)
|
||
.strokeBorder(plateStroke, lineWidth: plateStrokeWidth)
|
||
.shadow(color: haloColor, radius: 5)
|
||
}
|
||
|
||
/// The halo's glow — clear whenever the halo doesn't draw, because a conditional modifier
|
||
/// would change the overlay's identity where a clear shadow just paints nothing.
|
||
private var haloColor: Color {
|
||
guard isSelected, Accommodations.drawsSelectionHalo(contrast: contrast) else { return .clear }
|
||
return Color.accentColor.opacity(0.3)
|
||
}
|
||
|
||
private var plateStroke: AnyShapeStyle {
|
||
if isSelected {
|
||
AnyShapeStyle(Color.accentColor.opacity(Accommodations.accentOpacity(0.55, contrast: contrast)))
|
||
} else if Accommodations.drawsRestingBorder(contrast: contrast) {
|
||
AnyShapeStyle(.separator)
|
||
} else {
|
||
AnyShapeStyle(.clear)
|
||
}
|
||
}
|
||
|
||
private var plateStrokeWidth: CGFloat {
|
||
// The halo's edge is a hairline; with the halo off (Increase Contrast) the selected edge
|
||
// returns to the solid ring's 1.5 base, which `borderWidth` then strengthens as ever.
|
||
let base: CGFloat = isSelected && !Accommodations.drawsSelectionHalo(contrast: contrast) ? 1.5 : 1
|
||
return Accommodations.borderWidth(base, contrast: contrast)
|
||
}
|
||
|
||
// 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 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 `BoardMetrics` 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: @MainActor (ItemID) -> Void
|
||
|
||
/// Increase Contrast, for the editor well's stroke below (10-accessibility.md; `Accommodations`).
|
||
@Environment(\.colorSchemeContrast) private var contrast
|
||
|
||
/// The board's ruler (`BoardZoom`) — the same environment the real face reads, so the placeholder
|
||
/// and the card that replaces it are drawn at one zoom.
|
||
@Environment(\.boardZoom) private var zoom
|
||
|
||
/// The live body metric — the same one the real face reads, which is what makes "the numbers are
|
||
/// the same numbers rather than equal ones" survive the move to font-derived metrics.
|
||
private var pointSize: CGFloat { zoom.bodyPointSize }
|
||
|
||
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) }
|
||
}
|
||
)
|
||
.boardFont(.body)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
||
.background(
|
||
RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
|
||
.fill(BoardSurface.cardPlate)
|
||
)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
|
||
// Increase Contrast takes the well to full strength and a point heavier — a 60%-alpha
|
||
// outline is exactly the kind of border the setting exists to rescue
|
||
// (`Accommodations`).
|
||
.strokeBorder(
|
||
Color.accentColor.opacity(Accommodations.accentOpacity(0.6, contrast: contrast)),
|
||
lineWidth: Accommodations.borderWidth(1.5, contrast: contrast)
|
||
)
|
||
)
|
||
}
|
||
|
||
/// **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
|
||
/// (`BoardMetrics`) — 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: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
|
||
Image(systemName: ItemSymbol.card)
|
||
.foregroundStyle(.secondary)
|
||
.imageScale(.medium)
|
||
Text(store.transient.newCardPlaceholder?.draftTitle ?? "")
|
||
.boardFont(.body)
|
||
.lineLimit(4)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
||
.padding(.leading, BoardMetrics.cardStripeWidth(bodyPointSize: pointSize))
|
||
.background(
|
||
RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
|
||
.fill(BoardSurface.cardPlate)
|
||
)
|
||
}
|
||
|
||
/// 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 one-container 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 }) {
|
||
store.select([lane], in: .board)
|
||
}
|
||
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.
|
||
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()
|
||
}
|
||
}
|
||
}
|