The full pointer grammar of 04-interactions.md § Selection, stated once as a pure function (SelectionGrammar) and reached through one store funnel from every click surface — card face, lane header, lane empty space, trash row: - Plain click replaces and anchors; the lane surfaces (header and empty space alike, per the settled one-lane-click-behavior rule) toggle off on a sole-membership repeat. - ⌘-click toggles within a homogeneous set; crossing any axis — cards XOR lanes, live XOR trashed, card entries XOR lane entries in the trash — degrades to a replace, so no click can produce a mixed selection. - ⇧-click ranges from the anchor in the (side, kind) order list: flatten order for cards, lane order for lanes, the trash's deterministic sort filtered to kind — the pointer twin of the keyboard's boundary rule (the keyboard goes inert, the pointer skips). - The rubber band (MarqueeSession/MarqueeMath) arms from lane empty space, the board backdrop, and the trash column; side frozen at the origin, trash bands homogeneous by topmost kind, frames self-registered in strip space, geometric begin guard, never animated. - Fast plain double-click opens the card window (⌘↩'s pointer twin); Select All answers the standard Edit menu item via the responder chain, trash- and kind-respecting. - The range anchor lives in TransientBoardState beside the selection and obeys the same reload vanish rule. 659 unit tests (28 new in SelectionGrammarTests). Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
832 lines
40 KiB
Swift
832 lines
40 KiB
Swift
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 click selects the lane — toggling off on a repeat, exactly as empty space does
|
||
/// (04-interactions.md § Selection, settled) — and 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.
|
||
///
|
||
/// ### 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
|
||
|
||
/// The strip's reorder session, so the header knows whether *it* is the lane in flight.
|
||
let reorder: LaneReorderSession
|
||
|
||
let headerDrag: LaneHeaderDrag
|
||
|
||
/// 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
|
||
|
||
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)
|
||
}
|
||
|
||
// 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 }
|
||
.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)
|
||
}
|
||
|
||
/// 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 {
|
||
// **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.
|
||
store.click(
|
||
SelectionTarget(id: lane.id, kind: .lane, side: .live),
|
||
modifier: .current,
|
||
togglesOnRepeat: true
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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
|
||
Group {
|
||
switch slot {
|
||
case let .card(card):
|
||
CardFaceView(
|
||
store: store,
|
||
card: card,
|
||
registry: marquee.registry,
|
||
openCard: openCard
|
||
)
|
||
case .placeholder:
|
||
NewCardStubView(store: store, openCard: openCard)
|
||
}
|
||
}
|
||
// "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))
|
||
}
|
||
}
|
||
.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)
|
||
}
|
||
// "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 }
|
||
}
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
|
||
/// 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 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
|
||
|
||
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 }
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: cornerRadius)
|
||
.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. 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)
|
||
})
|
||
.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: - 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)
|
||
}
|
||
|
||
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()
|
||
}
|
||
}
|
||
}
|