Files from Finder land on the board per 04-interactions.md § Drag & drop: - Dropped on a card, they copy into its attachments/ (any type, multi-file), the face highlighting while hovered; the hovered card is resolved by hit-testing the same analytic masonry frames the card zones are built from, so attach-beats-create adds no drop region and cannot drift from the dispatch. - Dropped on lane empty space, one card per file — filename minus extension as the title (a blank stem omits the key), fresh GUID, rank at the drop position through the ordinary insertion machinery, the file attached — all in one bracket; a failed import removes the just-minted card, so creating-then-abandoning never leaves an empty card behind. - Every board drop surface now declares .fileURL beside the two board types (the single-target-dispatch rule); tombstoned surfaces are inert; file sessions ride a distinct session mode with their own watchdog and no hysteresis, leaving the board-drag machinery untouched. - Also: four empty fixture directories pinned with .keep files so git preserves them, and a test-only visibility fix in DragSessionTests. 811 unit tests (12 new). Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
1169 lines
57 KiB
Swift
1169 lines
57 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 Style…, the
|
||
/// quick-style recents row and the Width stepper today; Rename and Delete are m5's context-menus
|
||
/// card, and their rows go into that same builder rather than into a second menu.
|
||
///
|
||
/// ### What is still a later card's
|
||
///
|
||
/// The search-aware filtering behind the count belongs to a later milestone. The card face is real
|
||
/// (`CardFaceView`); what it still owes is the cut treatment and the sole-selected card's attachment
|
||
/// carousel.
|
||
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)
|
||
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { measuredHeight = $0 }
|
||
// **This lane's drop target**, on the whole body. It accepts *every* session type and routes
|
||
// internally — card sessions against this lane's masonry zones, lane sessions forwarded to
|
||
// the strip's logic, external Finder file sessions against those same zones — because
|
||
// single-target dispatch has no fall-through (DRAG-REORDER.md).
|
||
.onDrop(of: boardDropTypes, delegate: LaneDropDelegate(context: drops, laneID: lane.id))
|
||
}
|
||
|
||
// MARK: - Header
|
||
|
||
private var header: some View {
|
||
headerContent
|
||
// The bar is the drag surface, so it must be hit-testable across its whole width —
|
||
// including the empty stretch between the badge and the button.
|
||
.contentShape(Rectangle())
|
||
// **The header toggles like empty space** (04-interactions.md § Selection, settled): "a
|
||
// click on the already-selected lane's header unselects, one lane-click behavior
|
||
// everywhere, so a full lane keeps a pointer path out of selection". Hence the same
|
||
// `togglesOnRepeat` the empty space passes — the two surfaces differ only in where they
|
||
// are. `.onTapGesture` beside `.onDrag` is the click-versus-drag split: the system holds
|
||
// the drag off until the pointer actually moves, so a click is never a drag.
|
||
.onTapGesture {
|
||
store.click(
|
||
SelectionTarget(id: lane.id, kind: .lane, side: .live),
|
||
modifier: .current,
|
||
togglesOnRepeat: true
|
||
)
|
||
}
|
||
.onDrag(startLaneDrag, preview: { dragReplica })
|
||
.overlay(alignment: .trailing) { newCardButton }
|
||
.contextMenu { laneMenu }
|
||
// The lane's half of the Style… popover. Anchored on the header because that is the
|
||
// lane's own furniture — `styleEditorPresentation` decides whether this lane is the
|
||
// session's presenting anchor at all.
|
||
.popover(isPresented: styleEditorPresentation(store, anchor: lane.id), arrowEdge: .bottom) {
|
||
StyleEditorPopover(store: store, recents: appModel.styleRecents)
|
||
}
|
||
}
|
||
|
||
/// The lane's colour as C7 — "a lane's color paints a full-width band along its top edge; the
|
||
/// surfaces themselves keep the standard chrome, so colored title text never sits on a colored
|
||
/// fill" (03-board-ui.md § Styling ▸ Capabilities, settled in the pathfinder's treatment
|
||
/// shootout).
|
||
///
|
||
/// A value that resolves to nothing paints **no band**, and the bytes stay on disk exactly as
|
||
/// written — the card stripe's rule, for its reason: there is no sensible default colour for
|
||
/// "the author meant something we can't read", and a wrong colour is worse than none.
|
||
@ViewBuilder
|
||
private var accentBand: some View {
|
||
if let color = Palette.color(for: lane.background) {
|
||
UnevenRoundedRectangle(topLeadingRadius: cornerRadius, topTrailingRadius: cornerRadius)
|
||
.fill(color)
|
||
.frame(height: bandHeight)
|
||
.frame(maxWidth: .infinity)
|
||
// Decoration only: the header below it owns the lane's click and drag.
|
||
.allowsHitTesting(false)
|
||
}
|
||
}
|
||
|
||
// MARK: - The lane's one context menu
|
||
|
||
/// Rename, Style…, the quick-style recents row, the Width control, Delete (11-command-nexus.md ▸
|
||
/// Context menus) — the style trio and the width stepper today.
|
||
@ViewBuilder
|
||
private var laneMenu: some View {
|
||
// m5-context-menus: Rename (a twin of Board ▸ Rename) and Delete (a twin of File ▸ Delete)
|
||
// belong to the card that brings the selection model and the delete command; both are rows
|
||
// of *this* menu when they land, not of a second one.
|
||
StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget)
|
||
|
||
Divider()
|
||
|
||
widthControl
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
|
||
private var headerContent: some View {
|
||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||
Image(systemName: ItemSymbol.name(lane.icon, fallback: ItemSymbol.lane))
|
||
.foregroundStyle(.secondary)
|
||
.imageScale(.medium)
|
||
headerTitle
|
||
countBadge
|
||
Spacer(minLength: 0)
|
||
}
|
||
// Reserves the button's width so a long title truncates before it collides, and keeps the
|
||
// button out of the gestured region.
|
||
.padding(.trailing, 22)
|
||
.padding(.horizontal, 4)
|
||
}
|
||
|
||
/// The title, or the rename editor when this lane is the one being renamed.
|
||
///
|
||
/// A lane's **only** rename path is Board ▸ Rename (04-interactions.md ▸ Selection: "the menu
|
||
/// item is a lane's only rename path, since Return on a lane creates a card"), so nothing in
|
||
/// this view opens the editor — it only renders one that is already open.
|
||
@ViewBuilder
|
||
private var headerTitle: some View {
|
||
if isRenaming {
|
||
InlineTitleField(
|
||
text: renameDraft,
|
||
prompt: "Lane name",
|
||
onCommit: { store.commitRename() },
|
||
onAbandon: { store.transient.discardRename() },
|
||
onFocusLoss: { store.commitRename() },
|
||
// A lane has no card window; ⌘↩ still commits, which is the half of the rule that
|
||
// applies (04 ▸ Grammar's carve-out is "commits the edit — placeholder or rename —
|
||
// and open[s] the card window", and only a card has one to open).
|
||
onCommitAndOpen: { store.commitRename() }
|
||
)
|
||
.font(.headline)
|
||
} else {
|
||
Text(lane.title.value ?? "Untitled")
|
||
.font(.headline)
|
||
.foregroundStyle(lane.title.value == nil ? .secondary : .primary)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
}
|
||
}
|
||
|
||
/// The card-count badge — quiet, secondary (03-board-ui.md § Lane).
|
||
///
|
||
/// **It counts exactly what the body renders**, because it reads the same `renderedCards` the
|
||
/// masonry iterates. That is deliberate rather than incidental: "The count reads the search
|
||
/// filter like every other surface — during a search it shows the visible count, not the
|
||
/// total", so when m5's search card narrows `renderedCards` to the filter's survivors the badge
|
||
/// follows by construction, with no second rule to keep in step.
|
||
private var countBadge: some View {
|
||
Text("\(renderedCards.count)")
|
||
.font(.caption)
|
||
.monospacedDigit()
|
||
.foregroundStyle(.secondary)
|
||
.padding(.horizontal, 6)
|
||
.padding(.vertical, 1)
|
||
.background(Capsule().fill(.quaternary))
|
||
}
|
||
|
||
/// The new-card button — a **pointer twin** of File ▸ New Card whose click *names its target*:
|
||
/// "the lane header's new-card button overrides [the ⌘N target] rule — the click names its
|
||
/// target lane, selection notwithstanding" (11-command-nexus.md ▸ Pointer grammar, settled), so
|
||
/// it passes this lane and no anchor rather than consulting `NewCardTarget`.
|
||
private var newCardButton: some View {
|
||
Button {
|
||
store.transient.beginPlaceholder(inLane: lane.id)
|
||
} label: {
|
||
Image(systemName: "plus")
|
||
.imageScale(.small)
|
||
.foregroundStyle(.secondary)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel("New card in \(lane.title.value ?? "Untitled")")
|
||
// Mutating, so the read-only lock disables it like every other write path
|
||
// (02-architecture.md § The lock's scope), and the focused-editor rule closes it while an
|
||
// inline editor is open (04 ▸ Grammar) — the pointer twin of a disabled menu item.
|
||
.disabled(store.isReadOnly || store.isEditingInline)
|
||
}
|
||
|
||
// MARK: - The lane drag
|
||
|
||
/// Begins the lane's system drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
||
///
|
||
/// **Dragging any member of a multi-selection drags the whole selection**, in board order —
|
||
/// which is the lane level's flatten order. A lane outside the selection drags alone, standard
|
||
/// macOS targeting.
|
||
///
|
||
/// Refused under the read-only lock and while an inline editor is focused, like every other
|
||
/// mutating gesture (02-architecture.md § The lock's scope; 04 ▸ Grammar's focused-editor rule).
|
||
/// A refusal is an item provider carrying nothing: no session begins, every drop target declines,
|
||
/// and the image snaps back.
|
||
private func startLaneDrag() -> NSItemProvider {
|
||
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
||
let selection = store.selection
|
||
let ids: Set<ItemID> = selection.liveness == .live
|
||
&& selection.ids.contains(lane.id)
|
||
&& selection.ids.count > 1
|
||
? selection.ids
|
||
: [lane.id]
|
||
|
||
let members = store.snapshot.lanes.filter { !$0.isDeleted && ids.contains($0.id) }
|
||
guard !members.isEmpty else { return NSItemProvider() }
|
||
|
||
let root = store.rootURL
|
||
let payload = DragPayload(
|
||
boardRoot: root,
|
||
kind: .lanes,
|
||
side: .live,
|
||
items: members.map {
|
||
DragPayload.Item(
|
||
id: $0.id.rawValue,
|
||
folder: root.appendingPathComponent($0.id.rawValue, isDirectory: true).path,
|
||
title: $0.title.value
|
||
)
|
||
}
|
||
)
|
||
drops.session.beginLanes(
|
||
members.map(\.id),
|
||
folders: payload.folders,
|
||
// 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,
|
||
registry: marquee.registry,
|
||
drops: drops,
|
||
openCard: openCard
|
||
)
|
||
case .placeholder:
|
||
NewCardStubView(store: store, 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.
|
||
.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.
|
||
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 placeholderPosition: Int?
|
||
if let placeholder = store.transient.newCardPlaceholder, placeholder.laneID == lane.id {
|
||
placeholderPosition = BoardStore.insertionIndex(after: placeholder.anchorCardID, among: renderedCards)
|
||
?? result.count
|
||
}
|
||
|
||
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 position = placeholderPosition {
|
||
if let shadowPosition, position >= shadowPosition { position += heights.count }
|
||
result.insert(.placeholder, at: min(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.
|
||
///
|
||
/// This is also the collection m5's search filter narrows, which is what keeps the count badge
|
||
/// honest for free — see `countBadge`.
|
||
private var renderedCards: [Card] {
|
||
let hidden = drops.session.hiddenMembers(onBoardRooted: store.rootURL)
|
||
return lane.cards.filter { !$0.isDeleted && !hidden.contains($0.id) }
|
||
}
|
||
|
||
// 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.
|
||
private enum LaneSlot: Identifiable {
|
||
case card(Card)
|
||
case placeholder
|
||
/// 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)
|
||
// Constant, because there is only ever one placeholder in one lane at a time and it must
|
||
// keep its identity — and therefore its keyboard focus — while the user types.
|
||
case .placeholder: "placeholder"
|
||
// 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)" }
|
||
}
|
||
|
||
// 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).
|
||
///
|
||
/// ### Room for the carousel
|
||
///
|
||
/// The face is a top-aligned `VStack` and its two decorations — the accent stripe and the selection
|
||
/// stroke — are shapes in overlays, so both stretch to whatever height the content takes. That is
|
||
/// what lets m5's carousel expand *inside* this card without any of it being re-derived: the
|
||
/// masonry already isolates column heights, so a taller card pushes only the cards below it in its
|
||
/// own column.
|
||
private struct CardFaceView: View {
|
||
|
||
let store: BoardStore
|
||
let card: Card
|
||
|
||
/// Where the rubber band looks up what it is sweeping. The face registers its own drawn frame
|
||
/// here and takes it out again when it leaves — see `View.marqueeTarget`.
|
||
let registry: MarqueeTargetRegistry
|
||
|
||
/// 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.
|
||
private let cornerRadius: CGFloat = 8
|
||
|
||
/// K1 · left edge stripe (03-board-ui.md § Styling ▸ Capabilities, settled in the pathfinder's
|
||
/// treatment shootout).
|
||
private let stripeWidth: CGFloat = 4
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
titleRow
|
||
// m5-carousel: the sole selected card's paged attachment carousel expands here — below
|
||
// the title, inside this same plate, keyed on the selection transaction
|
||
// (03-board-ui.md § Card face). It needs `card.attachments` (already loaded) and this
|
||
// view's `isSelected`; nothing above it changes.
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(10)
|
||
// 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
|
||
)
|
||
)
|
||
.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: 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) — the style pair today.
|
||
@ViewBuilder
|
||
private var cardMenu: some View {
|
||
// m5-context-menus: Open (a twin of Board ▸ Open Card, always the clicked card alone —
|
||
// a card window is tied to one card), Rename, and Delete land with the selection-model and
|
||
// delete cards, as rows of this same menu.
|
||
StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget)
|
||
}
|
||
|
||
/// What this card's menu styles: the whole selection when this card is part of it, else this card
|
||
/// alone. Standard macOS — right-clicking 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)
|
||
}
|
||
|
||
// MARK: - Title row
|
||
|
||
private var titleRow: some View {
|
||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||
.foregroundStyle(iconTint)
|
||
.imageScale(.medium)
|
||
titleOrEditor
|
||
// The title takes the row's width so the indicator sits hard against the trailing
|
||
// edge — and so the rename field fills the same span the title occupied.
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
attachmentsIndicator
|
||
}
|
||
}
|
||
|
||
/// The title, or the rename editor when this card is the rename target. Unchanged from the
|
||
/// stub this face replaces: the four exits and their store calls are 04-interactions.md ▸
|
||
/// Grammar's, stated once in `InlineTitleField`.
|
||
@ViewBuilder
|
||
private var titleOrEditor: some View {
|
||
if isRenaming {
|
||
InlineTitleField(
|
||
text: draft,
|
||
prompt: "Card title",
|
||
onCommit: { store.commitRename() },
|
||
onAbandon: { store.transient.discardRename() },
|
||
// **Click-away commits** — a rename's rule, and the deliberate opposite of the
|
||
// placeholder's (04-interactions.md ▸ Grammar: "focus loss = commit, matching
|
||
// the card window's title field").
|
||
onFocusLoss: { store.commitRename() },
|
||
onCommitAndOpen: {
|
||
let id = card.id
|
||
store.commitRename()
|
||
openCard(id)
|
||
}
|
||
)
|
||
.font(.body)
|
||
} else {
|
||
Text(card.title.value ?? "Untitled")
|
||
.font(.body)
|
||
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
|
||
.lineLimit(4)
|
||
}
|
||
}
|
||
|
||
/// `iconColor`'s tint, or the standard secondary one. Deliberately not `AnyShapeStyle(.primary)`
|
||
/// on the fallback path: an uncoloured card icon is chrome, and chrome is secondary — the tint
|
||
/// exists to make a *hand-coloured* icon stand out from its neighbours.
|
||
private var iconTint: AnyShapeStyle {
|
||
if let color = Palette.color(for: card.iconColor) {
|
||
AnyShapeStyle(color)
|
||
} else {
|
||
AnyShapeStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
/// The one face chip in scope — shown only when the card actually has files, and quiet enough
|
||
/// that the title still dominates (03-board-ui.md § Card face). The count goes to the
|
||
/// accessibility label rather than onto the face: it is useful to know, not to look at.
|
||
@ViewBuilder
|
||
private var attachmentsIndicator: some View {
|
||
if !card.attachments.isEmpty {
|
||
Image(systemName: "paperclip")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.accessibilityLabel("\(card.attachments.count) attachments")
|
||
}
|
||
}
|
||
|
||
/// K1 · left edge stripe, painted with the resolved `background` — "a card's [colour paints] a
|
||
/// stripe along its left edge; the surfaces themselves keep the standard chrome, so coloured
|
||
/// title text never sits on a coloured fill" (03-board-ui.md § Styling ▸ Capabilities).
|
||
///
|
||
/// A value that resolves to nothing — a typo'd palette name, a malformed hex, a sequence where
|
||
/// a scalar belongs — draws **no stripe**, and the value stays on disk exactly as written.
|
||
/// A `Shape` rather than a sized rectangle so it takes the plate's full height whatever the
|
||
/// content does, m5's carousel expansion included.
|
||
@ViewBuilder
|
||
private var accentStripe: some View {
|
||
if let color = Palette.color(for: card.background) {
|
||
UnevenRoundedRectangle(topLeadingRadius: cornerRadius, bottomLeadingRadius: cornerRadius)
|
||
.fill(color)
|
||
.frame(width: stripeWidth)
|
||
// Decoration only: the whole plate is one click target for selection.
|
||
.allowsHitTesting(false)
|
||
}
|
||
}
|
||
|
||
// MARK: - 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.
|
||
private struct NewCardStubView: View {
|
||
|
||
let store: BoardStore
|
||
let openCard: (ItemID) -> Void
|
||
|
||
var body: some View {
|
||
Group {
|
||
if isEditing {
|
||
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)
|
||
} else {
|
||
Text(store.transient.newCardPlaceholder?.draftTitle ?? "")
|
||
.font(.body)
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(4)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(10)
|
||
.background(RoundedRectangle(cornerRadius: 8).fill(.background.secondary))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 8)
|
||
.strokeBorder(Color.accentColor.opacity(0.6), lineWidth: 1.5)
|
||
)
|
||
}
|
||
|
||
private var isEditing: Bool {
|
||
store.transient.newCardPlaceholder?.phase == .editing
|
||
}
|
||
|
||
/// 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()
|
||
}
|
||
}
|
||
}
|