import SwiftUI // 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 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 /// 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 /// 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 cardStack } .padding(6) .background(selectionBackground) .overlay(selectionStroke) } // 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) } } /// 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. /// /// 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 { 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 — **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). /// /// 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 { 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 { 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 { 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() } } }