Build lane chrome — title bar, badge, inline rename

The lane title bar becomes real: leading SF Symbol (hand-written names
render leniently, unknown ones fall back to the level default), title
or secondary untitled placeholder, a quiet count badge that counts
exactly the cards the body renders (so the m5 search filter is
followed by construction), and a new-card button. The whole bar is
the reorder drag surface — no grip — with click-vs-movement splitting
select from drag; a pure proposal function maps the drag to an
insertion index and release commits through the Writer's same-parent
degenerate reorder, compacting and retrying when midpoint precision
runs out. Clicking never edits: inline rename is Return on the sole
selected card or Board > Rename for either kind, a third transient
editor beside the placeholder that tracks its target by UUID, commits
on focus loss, discards silently when the target vanishes, and
removes the title key on an empty commit. The new-card placeholder
renders at last — the settled Cmd-N target rule (pure, tested) files
it after the anchor card, at a selected lane's bottom, or into the
last-active lane; Return commits and re-selects the lane, Cmd-Return
also opens the card window, and a failed create discards the overlay.
New Card / New Lane / Rename land in the menus with focused-editor
and read-only validation; rename gets its own WriteOperation case in
the banner vocabulary. 59 new tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 08:52:24 -04:00
parent ff3ba298f0
commit b35566e0fe
21 changed files with 2855 additions and 79 deletions
+105 -7
View File
@@ -20,6 +20,107 @@ extension FocusedValues {
}
}
// MARK: - Shared validation
/// The two conditions **every** board-mutating menu item disables on, in one place.
///
/// - **The read-only lock** (02-architecture.md § The lock's scope): "every mutating command
/// disables via menu validation" across every window sharing the store. An item that is going to
/// be refused should not look available.
/// - **The focused-editor rule** (04-interactions.md Grammar, settled): "while an inline title
/// editor rename or the new-card placeholder is focused, board-scoped menu commands (Delete,
/// New Card, Paste, Move, Style, ) disable via menu validation" and the keyboard belongs to the
/// text domain. The one carve-out the design names is Open Card , which stays enabled to commit
/// the edit and open the window it is not a menu item yet (m5), and when it is, it is the one
/// item that must *not* read this property.
///
/// Stated once rather than repeated per item, because the interesting failure mode is an item that
/// quietly forgets half of it.
extension BoardStore {
var acceptsBoardMutations: Bool {
!isReadOnly && !isEditingInline
}
}
// MARK: - Creation items
/// File New Card (N) and File New Lane (N) 11-command-nexus.md's two creation rows.
///
/// **New Card resolves its target through `NewCardTarget`**, the N target rule as a pure function,
/// and uses the *same* answer for its `disabled` state as for its action: a `nil` resolution is the
/// zero-lane board, where "card creation and card paste have no target New Card, Return-creation,
/// and Paste with a card payload disable via menu validation until a lane exists"
/// (04-interactions.md The map). Two derivations of that condition would be two chances to
/// disagree.
///
/// **New Lane is enabled whenever the board accepts writes.** It is the way *out* of a zero-lane
/// board "New Lane (N) is one way in" so it can have no selection precondition at all. The
/// lane it creates is untitled and no editor opens on it; see `BoardStore.createLane`.
struct BoardCreationCommands: View {
@FocusedValue(\.boardStore) private var store
var body: some View {
Button("New Card") {
guard let store, let target = newCardTarget else { return }
store.transient.beginPlaceholder(inLane: target.laneID, after: target.anchorCardID)
}
.keyboardShortcut("n", modifiers: .command)
.disabled(newCardTarget == nil)
Button("New Lane") {
store?.createLane()
}
.keyboardShortcut("n", modifiers: [.shift, .command])
.disabled(store?.acceptsBoardMutations != true)
}
/// Where N would file a card, or `nil` when it cannot no focused board, a board that refuses
/// writes, an inline editor holding the keyboard, or a board with no lanes.
private var newCardTarget: NewCardTarget.Resolution? {
guard let store, store.acceptsBoardMutations else { return nil }
return NewCardTarget.resolve(
selection: store.selection,
lastActiveLaneID: store.transient.lastActiveLaneID,
snapshot: store.snapshot
)
}
}
// MARK: - Rename
/// Board Rename no default chord, deliberately (11-command-nexus.md: " (cards: Return in
/// place)"), and remappable like any other item.
///
/// It "exists for completeness and remapping" for cards, whose real path is Return, and it is a
/// **lane's only rename path**: Return on a lane creates a card, so without this item a lane could
/// never be renamed at all (04-interactions.md Selection).
///
/// Validation is the sole-selected-live-item rule card or lane, either kind, exactly one. A
/// tombstoned selection never enables it: "everything edit-shaped is disabled on tombstoned
/// selections" (04 The trash), which `ItemReferenceSet`'s liveness side answers directly.
struct BoardRenameCommand: View {
@FocusedValue(\.boardStore) private var store
var body: some View {
Button("Rename") {
guard let store, let target = renameTarget else { return }
store.transient.beginRename(of: target.id, currentTitle: target.title)
}
.disabled(renameTarget == nil)
}
private var renameTarget: (id: ItemID, title: String?)? {
guard let store, store.acceptsBoardMutations else { return nil }
let selection = store.selection
guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first,
let item = BoardStore.liveItem(id, in: store.snapshot)
else { return nil }
return (id: id, title: item.title)
}
}
// MARK: - Lane width items
/// Increase / Decrease Lane Width **the width stepper's keyboard face** (03-board-ui.md § Lane,
@@ -31,9 +132,7 @@ extension FocusedValues {
/// **Validation is the sole-selected-lane rule.** Both items are enabled only when the focused
/// board's selection resolves to exactly one live lane; a card selection, a multi-selection, a
/// trash-side selection and an empty one all disable them. Decrease additionally disables at one
/// unit, which is the floor. Nothing selects a lane yet the lane-chrome card wires the header
/// click (04-interactions.md Selection) so these validate-disable in today's build, which is
/// expected rather than broken.
/// unit, which is the floor.
struct LaneWidthCommands: View {
@FocusedValue(\.boardStore) private var store
@@ -54,11 +153,10 @@ struct LaneWidthCommands: View {
/// The sole selected live lane, or `nil` the whole of these items' validation.
///
/// A read-only board disables every mutating command (02-architecture.md § The lock's scope), so
/// the lock is folded in here rather than left for the write to refuse: an item that is going to
/// fail should not look available.
/// The lock and the open-editor rule are folded in through `acceptsBoardMutations` rather than
/// left for the write to refuse: an item that is going to fail should not look available.
private var selectedLane: Lane? {
guard let store, !store.isReadOnly else { return nil }
guard let store, store.acceptsBoardMutations else { return nil }
let selection = store.selection
guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first else { return nil }
return store.snapshot.lanes.first { $0.id == id && !$0.isDeleted }
+196 -11
View File
@@ -15,11 +15,22 @@ import SwiftUI
/// keyboard face re-divides the existing window width across the new unit total, compressing the
/// siblings and never touching the window (`BoardStore.setLaneWidth`).
///
/// ### The three interactions it hosts
///
/// - **Lane resize** the right-edge grab strip (above).
/// - **Lane reorder** the whole title bar is the drag surface (`LaneReorderSession`,
/// `LaneReorderMath`); the travelling lane rides above its siblings while they show the would-be
/// order.
/// - **The keyboard's narrow slice** Return's create/rename dispatch and Escape's step outward.
///
/// ### What is deliberately not here yet
///
/// Selection, drag and drop, the trash quasi-lane, the toolbar, search, styling and the lane context
/// menu all belong to later milestone cards. This view is the layout and the resize interaction, and
/// the chrome inside `LaneView` is a placeholder those cards replace.
/// The trash quasi-lane, the toolbar, search, styling, the lane context menu, and drag & drop's real
/// machinery (multi-drag, cross-board locality, the shadow's hold rule) all belong to later
/// milestone cards, and the card face inside `LaneView` is still a stub those cards replace. The
/// **selection grammar** here is likewise minimal a click replaces the selection, and that is all:
/// -click toggling, -click ranges, the rubber band and the cards-XOR-lanes homogeneity rule are
/// m5's selection-model card.
struct BoardView: View {
let store: BoardStore
@@ -29,10 +40,23 @@ struct BoardView: View {
/// after the first body evaluation.
let window: @MainActor () -> NSWindow?
/// Opens a card's window 's second half (04-interactions.md Grammar). A closure from
/// `BoardWindowHost` rather than an `openWindow` call here, because building a `CardWindowRef`
/// needs the board's own window ref, which is the host's identity and not the board's.
let openCard: (ItemID) -> Void
/// One resize at a time, per window. `@State` so it lives exactly as long as this board window's
/// view does, which is the interaction's whole lifetime.
@State private var resize = LaneResizeSession()
/// One reorder at a time, per window same lifetime, same reasoning.
@State private var reorder = LaneReorderSession()
/// Whether the strip holds keyboard focus, which is what makes the grammar keys arrive. Restored
/// deliberately whenever an inline editor closes: the field that had focus is gone, and Return
/// must go back to meaning create/rename rather than nothing at all.
@FocusState private var isBoardFocused: Bool
/// The inter-lane gap, and the strip's outer margin one number, because the standard-width
/// formula counts `units + 1` of them (03-board-ui.md § Layout; `LaneLayoutMath.standardWidth`).
private let spacing: CGFloat = 12
@@ -51,16 +75,38 @@ struct BoardView: View {
stripWidth: viewport.size.width,
totalUnits: LaneLayoutMath.totalUnits(of: lanes),
gap: spacing)
// The lanes in the order the strip should *show* them: their snapshot order at rest, and
// the drag's would-be order while a reorder is in flight which is how the siblings
// reflow to make room (04-interactions.md Drag and drop). The proposal is recomputed
// here on every render, so a foreign reload mid-drag simply moves the zones (rule 1 of
// that section's re-grounding trio).
let shown = shownLanes(lanes, standard: standard)
HStack(alignment: .top, spacing: spacing) {
ForEach(lanes) { lane in
laneSlot(lane, standard: standard)
ForEach(Array(shown.enumerated()), id: \.element.id) { position, lane in
laneSlot(lane, at: position, among: shown, standard: standard)
}
}
.padding(spacing)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
// The board is a focus target so the grammar keys reach it at all. The focus *ring* is off:
// the strip is the window's content, not a control, and a rectangle around the whole board
// would read as an error state.
.focusable()
.focusEffectDisabled()
.focused($isBoardFocused)
.onAppear { isBoardFocused = true }
.onChange(of: store.isEditingInline) { _, editing in
// An editor took focus and has now given it back. Without this the strip stays unfocused
// after every rename and Return silently stops working.
if !editing { isBoardFocused = true }
}
.onKeyPress(.return) { handleReturn() }
.onKeyPress(.escape) { handleEscape() }
}
// MARK: - Lanes
/// One lane's strip slot, plus its trailing grab strip.
///
/// Normally a plain `LaneView` sized to its unit count's slot width (a wide lane swallows the
@@ -76,9 +122,15 @@ struct BoardView: View {
///
/// The outer frame is always the snapped slot width, so the `HStack` lays the other lanes out
/// off the tidy snapped layout regardless of the live overflow.
///
/// While this lane is being **reordered** the slot instead keeps its resting size and travels:
/// the offset is the gap between where the pointer has carried it and where it would rest under
/// the current proposal, so it tracks the cursor 1:1 while its siblings sit in the would-be
/// order beneath it (`zIndex(2)`, above even a resize).
@ViewBuilder
private func laneSlot(_ lane: Lane, standard: CGFloat) -> some View {
private func laneSlot(_ lane: Lane, at position: Int, among shown: [Lane], standard: CGFloat) -> some View {
let resizing = resize.isResizing(lane.id)
let dragging = reorder.isDragging(lane.id)
let units = resizing ? resize.units : LaneLayoutMath.displayUnits(of: lane)
let slotWidth = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing)
ZStack(alignment: .topLeading) {
@@ -93,11 +145,20 @@ struct BoardView: View {
// continuous width and not the not-yet-committed `lane.width`. The live width still
// narrows and widens the columns continuously, so the cards reflow under the cursor
// between ticks (free via `MasonryLayout`).
LaneView(lane: lane, columns: units)
.frame(width: resizing ? resize.liveWidth : slotWidth, alignment: .leading)
LaneView(
store: store,
lane: lane,
columns: units,
reorder: reorder,
headerDrag: headerDrag(at: position, among: shown, standard: standard),
openCard: openCard
)
.frame(width: resizing ? resize.liveWidth : slotWidth, alignment: .leading)
}
.frame(width: slotWidth, alignment: .topLeading)
.zIndex(resizing ? 1 : 0)
.offset(x: dragging ? travelOffset(at: position, among: shown, standard: standard) : 0)
.opacity(dragging ? 0.9 : 1)
.zIndex(dragging ? 2 : (resizing ? 1 : 0))
.overlay(alignment: .trailing) {
LaneResizeHandle(
store: store,
@@ -112,8 +173,10 @@ struct BoardView: View {
// (02-architecture.md § The lock's scope). It matters more here than elsewhere: a drag
// resizes the *window* on the way, so a refused commit would leave the window grown
// around a lane that snapped back and the lock's row is already saying why nothing
// can be written.
.disabled(store.isReadOnly)
// can be written. The focused-editor rule closes it too, like every board command, and
// so does a reorder in flight: two drags mutating one strip layout is not a state this
// view has a meaning for.
.disabled(store.isReadOnly || store.isEditingInline || reorder.isActive)
}
}
@@ -123,6 +186,128 @@ struct BoardView: View {
private var liveLanes: [Lane] {
store.snapshot.lanes.filter { !$0.isDeleted }
}
// MARK: - Reorder
/// The order the strip shows: the snapshot's at rest, the drag's proposal while one is in
/// flight. A drag whose lane has vanished from the snapshot shows the plain order and proposes
/// nothing its release then cancels (04 Drag and drop, "an emptied drag cancels itself").
private func shownLanes(_ lanes: [Lane], standard: CGFloat) -> [Lane] {
guard let (from, to) = proposal(among: lanes, standard: standard) else { return lanes }
return LaneReorderMath.reordered(lanes, from: from, to: to)
}
/// Where the dragged lane sits in `lanes` and where it would land `nil` when no reorder is in
/// flight, or when the lane it is carrying is no longer on the board.
private func proposal(among lanes: [Lane], standard: CGFloat) -> (from: Int, to: Int)? {
guard let id = reorder.laneID, let from = lanes.firstIndex(where: { $0.id == id }) else { return nil }
let to = LaneReorderMath.proposedIndex(
unitCounts: unitCounts(of: lanes),
draggedIndex: from,
dragCentreX: reorder.centre,
standard: standard,
gap: spacing
)
return (from, to)
}
/// How far the travelling lane is drawn from the slot it would rest in the pointer's position
/// minus the proposal's. Zero at the instant a tick lands, growing again as the pointer moves
/// on, which is what makes the replica read as *held* rather than as snapping.
private func travelOffset(at position: Int, among shown: [Lane], standard: CGFloat) -> CGFloat {
reorder.centre - LaneReorderMath.centre(
ofLaneAt: position,
unitCounts: unitCounts(of: shown),
standard: standard,
gap: spacing
)
}
/// The strip's half of a lane header's drag: where the lane rests now, and what a release means.
private func headerDrag(at position: Int, among shown: [Lane], standard: CGFloat) -> LaneHeaderDrag {
LaneHeaderDrag(
startCentre: {
LaneReorderMath.centre(
ofLaneAt: position,
unitCounts: unitCounts(of: shown),
standard: standard,
gap: spacing
)
},
commit: { commitReorder(standard: standard) }
)
}
/// Releases the drag: re-derive the proposal against the snapshot **as it is now** and write it.
///
/// Re-deriving rather than trusting the last rendered proposal is 04-interactions.md Drag and
/// drop's re-grounding rule at its most consequential moment: a reload that landed between the
/// last render and the release must not be written over. A lane that vanished in that window
/// yields no proposal and the release simply cancels "release with no valid proposal cancels;
/// items return, nothing is written".
///
/// `BoardStore.moveLane` owns the rest, the unchanged-index no-op included.
private func commitReorder(standard: CGFloat) {
defer { reorder.end() }
guard let id = reorder.laneID,
let (_, to) = proposal(among: liveLanes, standard: standard)
else { return }
store.moveLane(id, toIndex: to)
}
private func unitCounts(of lanes: [Lane]) -> [Int] {
lanes.map { LaneLayoutMath.displayUnits(of: $0) }
}
// MARK: - Grammar keys
/// **Return**, narrowly (04-interactions.md Grammar): a sole selected live card begins an
/// inline rename, a sole selected live lane begins a new-card placeholder at its bottom, and
/// everything else is ignored a multi-card selection is explicitly inert, and a lane's rename
/// path is Board Rename precisely because Return on a lane creates.
///
/// The full keyboard map arrows, -jumps, the escalation, , the moves is **m5's
/// keyboard-grammar card**. This is the creation/rename pair and nothing else.
///
/// Inert while an inline editor is open: "all grammar keys inert while a title editor is
/// focused". The field consumes Return itself, so this guard is belt over braces but the belt
/// matters, because a stray Return reaching here mid-edit would open a *second* editor.
private func handleReturn() -> KeyPress.Result {
guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
let selection = store.selection
guard selection.liveness == .live,
selection.ids.count == 1,
let id = selection.ids.first,
let target = BoardStore.liveItem(id, in: store.snapshot)
else { return .ignored }
if target.cardID == nil {
store.transient.beginPlaceholder(inLane: target.laneID)
} else {
store.transient.beginRename(of: id, currentTitle: target.title)
}
return .handled
}
/// **Escape steps outward one layer per press** (04 Grammar): abandon an open editor, else
/// clear the selection.
///
/// The middle step clearing an active search and returning focus to the board is m5's, and
/// it slots between these two once the search field exists.
///
/// The editors handle Escape themselves while they hold focus; this branch is the outer net for
/// the case where focus has drifted off the field with an editor still open, and it abandons
/// both kinds because at most one can be open at a time.
private func handleEscape() -> KeyPress.Result {
if store.isEditingInline {
store.transient.discardPlaceholder()
store.transient.discardRename()
return .handled
}
guard !store.selection.isEmpty else { return .ignored }
store.clearSelection()
return .handled
}
}
// MARK: - Resize shadow
+101
View File
@@ -0,0 +1,101 @@
import CoreGraphics
/// The lane-reorder drag's geometry, as pure arithmetic no view, no session, no snapshot
/// (`LaneReorderMathTests`). `LaneLayoutMath`'s sibling: that one owns the resting layout and the
/// right-edge resize, this one owns "where would the lane land if I let go now".
///
/// **Geometry-based, so the proposal is stable rather than jittery** (04-interactions.md Drag and
/// drop): the answer is a function of analytically computed resting positions and one pointer
/// coordinate never of measured mid-flight frames, which are garbage precisely during the reflow
/// they trigger (03-board-ui.md § Motion, "Motion never feeds back into logic").
///
/// **Width-aware by construction.** The design asks for "no reflow until the cursor reaches where
/// the dragged lane would actually land"; comparing against each remaining lane's *centre* is
/// exactly that a 3× lane's centre is three units along, so the drag has to travel most of that
/// lane's width before the board proposes stepping past it, and a 1× lane yields quickly.
///
/// ### What this deliberately is not
///
/// The full drag model the shadow's hold-until-a-new-candidate rule, multi-drag's N contiguous
/// shadows, cross-board locality with its copy/move badge, and the mid-drag re-grounding rules is
/// **m5's drag card**, which replaces this file's callers with the real `DropSlot`
/// (02-architecture.md § Layering Components). What is here is the within-board single-lane case
/// and nothing else, deliberately small enough to be obviously correct.
enum LaneReorderMath {
/// Where the dragged lane would land: an index into the ordered live lanes **with the dragged
/// lane removed**, so the result is in `0...(unitCounts.count - 1)` and `draggedIndex` itself
/// means "back where it started".
///
/// - Parameters:
/// - unitCounts: the ordered live lanes' display units (`LaneLayoutMath.displayUnits`), the
/// board as it currently is recomputed against each snapshot rather than frozen at drag
/// start, so a foreign lane add or tombstone mid-drag just moves the zones and the next
/// proposal targets the board as it now is (04 Drag and drop, rule 1).
/// - draggedIndex: the dragged lane's position in `unitCounts`.
/// - dragCentreX: the dragged lane's centre under the cursor, in strip coordinates (0 at the
/// strip's leading edge, outer margin included).
/// - standard: the 1× lane width (`LaneLayoutMath.standardWidth`).
/// - gap: the inter-lane gap, which is also the strip's outer margin.
///
/// Out-of-range `draggedIndex` yields `0` rather than trapping: the lane vanished under the
/// drag, and the caller's release-with-no-valid-proposal rule cancels anyway.
static func proposedIndex(
unitCounts: [Int],
draggedIndex: Int,
dragCentreX: CGFloat,
standard: CGFloat,
gap: CGFloat
) -> Int {
guard unitCounts.indices.contains(draggedIndex) else { return 0 }
var remaining = unitCounts
remaining.remove(at: draggedIndex)
// The remaining lanes' resting centres, left to right, in the layout they would have with
// the dragged lane gone which is the layout the siblings are already showing.
// Monotonically increasing, so "how many centres has the cursor passed" is both the answer
// and the reason it never oscillates: one threshold per slot, crossed once.
var index = 0
var x = gap
for units in remaining {
let width = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap)
guard dragCentreX > x + width / 2 else { break }
index += 1
x += width + gap
}
return index
}
/// The resting centre of the lane at `index` in a strip of `unitCounts`, in the same strip
/// coordinates `proposedIndex` reads.
///
/// Two callers, and they are the two halves of the drag: the gesture freezes this at drag start
/// as the origin its translation is measured from (the *physical pointer* being the only live
/// input 03 § Motion), and the view offsets the travelling lane from the centre it would rest
/// at under the current proposal, which is what makes the replica track the cursor while the
/// siblings sit in their would-be order.
static func centre(ofLaneAt index: Int, unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> CGFloat {
var x = gap
for (position, units) in unitCounts.enumerated() {
let width = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap)
if position == index { return x + width / 2 }
x += width + gap
}
return x
}
/// `unitCounts` (or any per-lane values) with the item at `from` moved to `to`, where `to` is
/// counted **with the item already removed** the ordering `proposedIndex` returns, applied.
///
/// Shared by the view (which reorders the lanes it lays out, so the siblings show the would-be
/// order) and by the drag's own centre arithmetic, so the two can never disagree about what the
/// proposal means.
static func reordered<T>(_ items: [T], from: Int, to: Int) -> [T] {
guard items.indices.contains(from) else { return items }
var result = items
let item = result.remove(at: from)
result.insert(item, at: min(max(0, to), result.count))
return result
}
}
+69
View File
@@ -0,0 +1,69 @@
import CoreGraphics
import Observation
/// Window-local state for an in-flight lane reorder the drag surface being the whole title bar
/// (03-board-ui.md § Lane, "no separate grip"). At most one runs per board window; `BoardView` owns
/// it as `@State` and hands it to the lanes.
///
/// `LaneResizeSession`'s sibling, and deliberately much smaller. It holds only what the *pointer*
/// contributes which lane, how far it has travelled, and the centre it started from because
/// everything else the proposal needs is read fresh from the snapshot at render time
/// (`LaneReorderMath.proposedIndex`). That split is 04-interactions.md Drag and drop's
/// re-grounding rule made structural: "the frozen-at-drag-start inputs are the *dragged items'*
/// sizes and the physical pointer only the analytic resting zones recompute against each new
/// snapshot", so a foreign lane add mid-drag cannot leave this session holding a stale board.
///
/// ### The click-versus-drag split
///
/// A plain click on the title bar selects the lane; only movement past `threshold` begins a
/// reorder (04 Selection: "the drag surface engages only on movement the click-vs-drag split
/// cards already have"). One gesture recognises both, so a hesitant click can never start a drag
/// and a drag can never also select.
@MainActor
@Observable
final class LaneReorderSession {
/// The lane being dragged; `nil` when idle. Observed flipping it drives the travelling lane's
/// z-order and offset, and the siblings' reflow into the proposed order.
private(set) var laneID: ItemID?
/// How far the pointer has travelled horizontally since the drag began. The *only* live input:
/// vertical movement is ignored outright, since lanes reorder along one axis.
private(set) var translation: CGFloat = 0
/// The dragged lane's resting centre at drag start, in strip coordinates the origin
/// `translation` is measured from, frozen exactly as 03-board-ui.md § Motion requires.
@ObservationIgnored private(set) var startCentre: CGFloat = 0
/// How far the pointer must move before a click becomes a drag. Small enough that a deliberate
/// drag feels immediate, large enough that the tremor in a click never reorders the board.
static let threshold: CGFloat = 4
var isActive: Bool { laneID != nil }
func isDragging(_ id: ItemID) -> Bool { laneID == id }
/// The dragged lane's centre under the cursor: the frozen start plus the physical translation,
/// and nothing measured.
var centre: CGFloat { startCentre + translation }
/// Begins a reorder of `laneID`, freezing the centre its travel is measured from.
func begin(laneID: ItemID, startCentre: CGFloat) {
self.laneID = laneID
self.startCentre = startCentre
self.translation = 0
}
func update(translation: CGFloat) {
guard isActive else { return }
self.translation = translation
}
/// Ends the drag, handing the caller nothing: the *commit* needs the current snapshot's lane
/// order, which `BoardView` has and this session deliberately does not. Idempotent, because a
/// gesture can end after the lane it was carrying has already vanished.
func end() {
laneID = nil
translation = 0
}
}
+500 -44
View File
@@ -1,15 +1,47 @@
import SwiftUI
/// One lane: a header and a vertically scrolling masonry of cards (03-board-ui.md § Lane).
// MARK: - The strip's half of the header drag
/// What the strip lends a lane so its **whole title bar** can be the drag surface (03-board-ui.md §
/// Lane, "no separate grip").
///
/// **The chrome here is deliberately minimal**, and the design's real lane is a later card: the
/// leading SF Symbol, the new-card button, the drag surface, the context menu (Rename, Style, the
/// quick-style recents row, the Width stepper, Delete), the colour accent band, inline rename and
/// the search-aware count all arrive with the lane-chrome milestone. What this view owes the
/// full-visibility layout card is the shape a header, a body that scrolls vertically only, and a
/// masonry whose interior column count is the lane's width and that is all it does.
/// The gesture lives on the header that is where the design puts it but the two things it needs
/// are the strip's: where this lane currently rests (the origin the translation is measured from)
/// and what a release means (a proposal computed against the live lane order, then a write). Both
/// arrive as closures rather than as values because both must be read at *gesture* time, not at
/// body-evaluation time.
@MainActor
struct LaneHeaderDrag {
/// This lane's resting centre in strip coordinates, read the instant the drag begins and frozen
/// for its duration (`LaneReorderSession.startCentre`).
let startCentre: () -> CGFloat
/// Commit the reorder at whatever the current proposal is, and end the session.
let commit: () -> Void
}
// 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 plain click selects the lane, movement past a small threshold begins a reorder
/// (`LaneReorderSession`). The one thing carved out of the drag region is the button, which sits in
/// an overlay outside the gesture so a click on it can never be read as the beginning of a drag.
///
/// ### What is still a later card's
///
/// The lane context menu (Rename, Style, the quick-style recents row, the Width stepper, Delete),
/// the colour accent band, and the search-aware filtering behind the count all belong to later
/// milestones. The **card face** is likewise still a stub `CardStubView` gains the leading icon,
/// the attachment chip, the cut treatment and the attachment carousel with the card-face card; what
/// it grows here is only what inline rename and click selection require.
struct LaneView: View {
let store: BoardStore
let lane: Lane
/// Interior masonry columns the lane's width units, or the resize session's snapped count
@@ -17,77 +49,501 @@ struct LaneView: View {
/// override it (see `BoardView.laneSlot`).
let columns: Int
/// The strip's reorder session, so the header knows whether *it* is the lane in flight.
let reorder: LaneReorderSession
let headerDrag: LaneHeaderDrag
/// 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
/// Spacing between cards, and between the interior columns.
private let cardSpacing: CGFloat = 8
var body: some View {
VStack(alignment: .leading, spacing: 8) {
header
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(liveCards) { card in
CardStubView(card: card)
}
}
.frame(maxWidth: .infinity, alignment: .topLeading)
}
cardStack
}
.padding(6)
.background(selectionBackground)
.overlay(selectionStroke)
}
/// The header title, or a quiet "Untitled" where there is none, plus the live card count.
///
/// Titles are optional at every level (03-board-ui.md § Card face): a missing `title` renders as
/// a secondary-styled placeholder rather than as an empty row. **Replaced wholesale by the
/// lane-chrome card**, which brings the icon, the count badge's real styling, the new-card
/// button, the drag surface and the context menu.
// 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())
.gesture(headerGesture)
.overlay(alignment: .trailing) { newCardButton }
}
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)
Text("\(liveCards.count)")
.font(.callout)
.foregroundStyle(.secondary)
.monospacedDigit()
Spacer(minLength: 0)
}
.padding(.horizontal, 4)
}
/// 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)
}
/// One gesture recognising both halves of 04-interactions.md Selection's click-vs-drag split:
/// "a plain click on the title bar selects the lane; the drag surface engages only on movement".
///
/// `minimumDistance: 0` so the release is seen even when nothing moved that release *is* the
/// click. `.global` coordinates because the strip's own space shifts as siblings reflow under
/// the proposal, and a translation measured against a moving frame is not a pointer delta.
private var headerGesture: some Gesture {
DragGesture(minimumDistance: 0, coordinateSpace: .global)
.onChanged { value in
// Selection stays live under the lock; reordering does not (02 § The lock's scope).
// The focused-editor rule holds a drag off too: a reorder is a board command.
guard !store.isReadOnly, !store.isEditingInline else { return }
if !reorder.isDragging(lane.id) {
guard abs(value.translation.width) > LaneReorderSession.threshold else { return }
reorder.begin(laneID: lane.id, startCentre: headerDrag.startCentre())
}
reorder.update(translation: value.translation.width)
}
.onEnded { _ in
if reorder.isDragging(lane.id) {
headerDrag.commit()
} else {
// A plain click on the header always selects unlike lane empty space, it does
// not toggle off. 04 gives the click-again-to-unselect behaviour to empty space
// only, and a full lane has no empty space to reach for.
store.select([lane.id], liveness: .live)
}
}
}
// 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.
private var cardStack: 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
switch slot {
case let .card(card):
CardStubView(store: store, card: card, openCard: openCard)
case .placeholder:
NewCardStubView(store: store, openCard: openCard)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.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)
}
.onTapGesture { toggleLaneSelection() }
}
}
/// What the masonry lays out: the rendered cards, plus the new-card placeholder when this lane
/// is the one being created into.
///
/// The overlay 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.
private var slots: [LaneSlot] {
var result = renderedCards.map(LaneSlot.card)
guard let placeholder = store.transient.newCardPlaceholder, placeholder.laneID == lane.id else {
return result
}
let position = BoardStore.insertionIndex(after: placeholder.anchorCardID, among: renderedCards)
result.insert(.placeholder, at: 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.
private var liveCards: [Card] {
///
/// 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] {
lane.cards.filter { !$0.isDeleted }
}
// MARK: - Selection
private var isSelected: Bool {
store.selection.liveness == .live && store.selection.ids.contains(lane.id)
}
/// Click on empty space: select, or clear when this lane is already *the* selection.
///
/// "Single click selects the lane (click again to unselect)". The toggle-off tests for a
/// sole-membership selection rather than mere containment, so a future -click multi-selection
/// of lanes is narrowed by a click rather than wiped by it the modifier grammar itself
/// (-click toggles, -click range-extends, rubber band, homogeneity enforcement) is **m5's
/// selection-model card**, and nothing here should pre-empt it.
private func toggleLaneSelection() {
if store.selection.liveness == .live, store.selection.ids == [lane.id] {
store.clearSelection()
} else {
store.select([lane.id], liveness: .live)
}
}
/// 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
/// 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
var id: String {
switch self {
case let .card(card): "card:\(card.id.rawValue)"
// 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"
}
}
}
// MARK: - Card stub
/// A card, as a rounded plate with its title **a stand-in, replaced by the card-face card**, which
/// brings the leading icon, the attachment chip, the selection and cut treatments, inline rename and
/// the sole-selected card's attachment carousel (03-board-ui.md § Card face).
/// A card, as a rounded plate with its title **still a stand-in**, replaced by the card-face card,
/// which brings the leading icon, the attachment chip, the cut treatment and the sole-selected
/// card's attachment carousel (03-board-ui.md § Card face).
///
/// It takes whatever width `MasonryLayout` proposes (one interior column = one standard width) and
/// sizes its own height to its content, which is what makes the masonry masonry: a taller card only
/// pushes the cards below it in its own column.
/// What it has grown here is only what this milestone owes: click-to-select with a selection
/// treatment, and the inline rename editor swapping in for the title when this card is the rename
/// target.
private struct CardStubView: View {
let store: BoardStore
let card: Card
let openCard: (ItemID) -> Void
var body: some View {
Text(card.title.value ?? "Untitled")
.font(.body)
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
.lineLimit(4)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(RoundedRectangle(cornerRadius: 8).fill(.background.secondary))
Group {
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)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(RoundedRectangle(cornerRadius: 8).fill(.background.secondary))
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 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.
.onTapGesture { store.select([card.id], liveness: .live) }
}
private var isSelected: Bool {
store.selection.liveness == .live && store.selection.ids.contains(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()
}
}
}
+82
View File
@@ -0,0 +1,82 @@
/// 04-interactions.md's **N target rule** (settled), as a pure function of the three things it
/// reads the selection, the last-active lane, and the snapshot (`NewCardTargetTests`).
///
/// The rule verbatim, and each clause's branch below:
///
/// > with a card selected, the new card is created in that card's lane, immediately after it
/// > (paste-anchor consistency); with a lane selected, appended at its bottom (Return consistency);
/// > with nothing selected or a **tombstoned** selection, which never anchors creation the
/// > **last-active lane** the lane that most recently held selection or a creation in this window
/// > session falling back to the first lane. **Zero-lane board**: card creation disable[s] via
/// > menu validation until a lane exists.
///
/// **A pure function rather than a method on the store** for the reason every rule in this codebase
/// that can be one is: the five branches are five lines of test rather than five UI states to drive,
/// and the menu item's `disabled` and its action then read the *same* answer instead of two
/// hand-kept-in-sync conditions.
///
/// ### What it deliberately does not decide
///
/// - **The lane header's new-card button overrides this rule entirely** (11-command-nexus.md
/// Pointer grammar, settled): "the click names its target lane, selection notwithstanding". That
/// call site passes its own lane and never comes here.
/// - **Return on a selected lane** is the same target as this rule's lane branch, but it is reached
/// by grammar rather than by the menu; it also passes its lane directly.
/// - **Multi-selections.** The rule speaks of "a card"/"a lane", singular, and a multi-selection has
/// no "it" to be immediately after. Anything but a sole selection falls through to the
/// last-active lane, which is the same answer an empty selection gets the honest reading, and
/// the one m5's selection-model card can refine if the design ever grows a plural case.
enum NewCardTarget {
/// Where a new card goes: which lane, and which card it lands immediately after (`nil` = the
/// lane's bottom). Exactly `NewCardPlaceholder`'s two anchoring fields, because that is what
/// this resolves *into*.
struct Resolution: Equatable {
let laneID: ItemID
let anchorCardID: ItemID?
}
/// The target, or `nil` when there is none **the zero-lane board**, where "New Card,
/// Return-creation, and Paste with a card payload disable via menu validation until a lane
/// exists". `nil` is therefore the menu item's `disabled` condition as well as its refusal, so
/// the two can never disagree.
///
/// - Parameters:
/// - selection: the board's current selection, liveness side included. A `.trashed` selection
/// "never anchors creation" and is treated exactly as an empty one the settled precedent
/// 04 Clipboard cites for paste, applied here to its source rule ("a trashed card's live
/// disk-lane never leaks in as 'the selected card's lane'").
/// - lastActiveLaneID: `TransientBoardState.lastActiveLaneID`, already cleared by the reload
/// rule if its lane vanished but re-checked here anyway, because a caller need not have
/// reloaded since the lane went.
static func resolve(
selection: ItemReferenceSet,
lastActiveLaneID: ItemID?,
snapshot: BoardModel
) -> Resolution? {
let lanes = snapshot.lanes.filter { !$0.isDeleted }
guard !lanes.isEmpty else { return nil }
if selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first {
for lane in lanes {
// A lane selected: appended at its bottom, Return consistency.
if lane.id == id { return Resolution(laneID: lane.id, anchorCardID: nil) }
// A card selected: its lane, immediately after it paste-anchor consistency.
if lane.cards.contains(where: { $0.id == id && !$0.isDeleted }) {
return Resolution(laneID: lane.id, anchorCardID: id)
}
}
// The id names nothing the board renders a selection the next reload will drop.
// Falls through to the last-active lane rather than refusing: the user pressed N and
// the board has lanes.
}
// Nothing selected, a tombstoned selection, a multi-selection, or a stale one: the lane that
// most recently held selection or a creation, and the first lane when there is no such lane
// (or it has since gone).
if let lastActiveLaneID, let lane = lanes.first(where: { $0.id == lastActiveLaneID }) {
return Resolution(laneID: lane.id, anchorCardID: nil)
}
return lanes.first.map { Resolution(laneID: $0.id, anchorCardID: nil) }
}
}