Files
lanework/Kanban/UI/Board/LaneView.swift
T
rzen 8f116934b4 Implement Finder file drops
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
2026-07-27 21:20:46 -04:00

1169 lines
57 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}
}
}