Files
lanework/Kanban/UI/Board/BoardView.swift
T
rzen 5ebf9fb90c Implement the motion language
One named home for every curve and duration — the two-voice split 03
fixes (snappy structural: drag reflow 0.18, delete 0.25, lane resize
0.2; smooth content-reflow 0.28), the scale-and-fade appear/disappear
transitions (cards 0.8, lanes 0.9), and a Reduce Motion variant on
every accessor (animations go instant, transitions go crossfade). A
post-migration grep holds the invariant: zero motion literals outside
Motion.swift. The animate-vs-snap split lands where Lanework's
one-way flow puts it: the store's reload seam. An app-mediated echo
applies its snapshot inside the structural transaction; foreign and
reconciling reloads — and every bracket-ending wholesale reload —
assign bare, because live-reload is the board becoming what's on
disk, not an event to perform. A window that merged foreign events
into an app-mediated span animates, deliberately: the ratified merge
rule makes it indistinguishable from a pure echo, and a test pins
that reading so a future mixed case fails loudly. The lane-reorder
reflow keys on the drop proposal alone (pointer tracking stays 1:1),
the resize session freezes its Reduce Motion answer at drag start so
the unit tick and the window resize can never disagree, and the m5
drag / m5 search / m7 undo voices are named seams waiting for their
call sites. 13 new tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-27 15:59:59 -04:00

479 lines
26 KiB
Swift

import AppKit
import SwiftUI
/// The lane strip — the board itself (03-board-ui.md § Layout — full visibility).
///
/// **Every lane is always on screen.** There is no horizontal scroll and no minimum lane width: the
/// window's width divides across the lanes' width units, a lane of n units taking n whole units of
/// that division, and resizing the window is the width control. A board with more units than the
/// window comfortably fits compresses every lane; that degenerate case is accepted, not floored
/// (the remedy is the user's — fewer units or a bigger window).
///
/// Two mechanisms change a lane's width and they are deliberately opposites (03-board-ui.md § Lane):
/// the **right-edge drag** grows or shrinks the *window* one standard width per snap so the other
/// lanes keep their exact pixels (`LaneResizeSession`), while the **stepper** — and its ⌥⌘→/⌥⌘←
/// 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.
///
/// - **The trash quasi-lane** — trailing, one fixed unit, joining and leaving the width division as
/// View ▸ Show Trash toggles it (`TrashLaneView`, 03-board-ui.md § Trash).
///
/// ### What is deliberately not here yet
///
/// The toolbar, search, and drag & drop's real machinery (multi-drag, cross-board locality, the
/// shadow's hold rule) all belong to later milestone cards. 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
/// How the resize session reaches the host window it grows and shrinks. Injected by
/// `BoardWindowHost`, which owns the window controller; a closure because the window attaches
/// after the first body evaluation.
let window: @MainActor () -> NSWindow?
/// The window's purge-alert host — see `TrashConfirmations` for why a menu item's confirmation
/// has to be presented from here.
let confirmations: TrashConfirmations
/// 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
/// The app-wide quick-style recents, for the board-anchored style editor (03-board-ui.md §
/// Styling ▸ Controls).
@Environment(AppModel.self) private var appModel
/// 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()
/// One drag out of the trash at a time, per window — same lifetime again.
@State private var trashDrag = TrashDragSession()
/// The name of the strip's coordinate space, which is what a drop out of the trash is resolved
/// in: `LaneLayoutMath.laneIndex` reads an x measured from the strip's leading edge, outer margin
/// included, and no global or lane-local space is that.
static let stripSpace = "board-strip"
/// Reduce Motion, for the transitions and the reflow curve below (10-accessibility.md). Read
/// from the environment here and handed to `Motion`, which owns what "reduced" means for each.
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// 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
var body: some View {
GeometryReader { viewport in
let lanes = liveLanes
// During a resize session the standard is FROZEN at its drag-start value: the window is
// animating mid-resize, so deriving the standard from the live viewport width would feed
// that animation back into every lane and pulse the whole strip. The window is sized on
// each tick so this frozen value equals what the viewport formula yields once the
// session ends — the handoff is seamless (see `LaneResizeSession`).
let standard = resize.isActive
? resize.standard
: LaneLayoutMath.standardWidth(
stripWidth: viewport.size.width,
// The trash's one fixed unit joins the division **only while shown**, which is
// the whole of "Show/Hide Trash is a re-divide trigger" (03-board-ui.md § Trash):
// the window is never touched, the existing width simply divides across one more
// unit and every lane compresses — a lane add's behaviour, exactly.
totalUnits: LaneLayoutMath.totalUnits(of: lanes, trashUnits: isTrashVisible ? 1 : 0),
gap: spacing)
// The drag's drop proposal, computed once because two things read it: the order the
// strip shows, and the key its reflow animates on. Recomputed on every render, so a
// foreign reload mid-drag simply moves the zones (rule 1 of 04-interactions.md ▸ Drag
// and drop's re-grounding trio).
let move = proposal(among: lanes, standard: standard)
// 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. A drag whose lane has vanished from the snapshot proposes nothing
// and shows the plain order; its release then cancels ("an emptied drag cancels itself").
let shown = move.map { LaneReorderMath.reordered(lanes, from: $0.from, to: $0.to) } ?? lanes
HStack(alignment: .top, spacing: spacing) {
ForEach(Array(shown.enumerated()), id: \.element.id) { position, lane in
laneSlot(lane, at: position, among: shown, standard: standard)
// "Appear/disappear is scale + fade … lanes ~0.9" (03-board-ui.md § Motion).
// A create, a delete and a Put Back all reach the strip as a lane arriving in
// or leaving this `ForEach`; whether that *performs* is decided upstream, at
// the reload that carried it (`Motion.reloadAnimates`) — a transition with no
// animated transaction around it is simply an appearance.
.transition(Motion.laneTransition(reduced: reduceMotion))
}
if isTrashVisible {
// Trailing, always — the quasi-lane has no position of its own to lose, which is
// also why it never appears in the reorder proposal's inputs (those are built
// from `liveLanes`).
TrashLaneView(
store: store,
confirmations: confirmations,
drag: TrashRowDrag { x in laneUnder(x: x, standard: standard) },
dragSession: trashDrag
)
.frame(width: LaneLayoutMath.slotWidth(units: 1, standard: standard, gap: spacing))
.frame(maxHeight: .infinity, alignment: .top)
// It arrives and leaves like a lane, because that is what it looks like — the
// column scales and fades while every real lane compresses to make room for its
// unit (03-board-ui.md § Motion, § Trash's re-divide). The transaction is the
// menu toggle's (`ShowTrashCommand`).
.transition(Motion.laneTransition(reduced: reduceMotion))
}
}
// The drag's reflow-to-make-room, keyed on the **drop proposal** and nothing else
// (03-board-ui.md § Motion: transactions are keyed narrowly, "on the drag's drop
// proposal … never on broad state"). The travelling lane's own offset changes on every
// pointer sample and none of those samples touch this value, so the replica keeps
// tracking the cursor 1:1 — which is the same bullet's other half. At the instant the
// proposal ticks, the lane's slot and its offset move by equal and opposite amounts, so
// animating both under one curve is what keeps it pinned under the cursor.
.animation(Motion.dragReflow(reduced: reduceMotion), value: move?.to)
.padding(spacing)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
// The space a drop out of the trash is resolved in — see `BoardView.stripSpace`. It goes
// on the padded container so x = 0 is the strip's leading edge with the outer margin
// included, which is the origin `LaneLayoutMath`'s arithmetic assumes.
.coordinateSpace(.named(Self.stripSpace))
}
.background(boardBackground)
.trashPurgeAlert(store: store, confirmations: confirmations)
// The board's own anchor for the Style… popover — the surface a board-targeted session hangs
// off, since the board has no item to attach to (`styleEditorPresentation`'s `nil` anchor).
.popover(isPresented: styleEditorPresentation(store, anchor: nil), arrowEdge: .top) {
StyleEditorPopover(store: store, recents: appModel.styleRecents)
}
// 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() }
.onKeyPress(keys: [.delete], phases: .down) { handleDelete($0) }
}
// MARK: - Styling
/// The board's `background`, painting "the board window's content background (the surface behind
/// and between lanes)" (03-board-ui.md § Styling ▸ Capabilities).
///
/// Unlike the lane band and the card stripe this one is a **fill**, because at board level that
/// is what the design asks for — and it is why the board is the level 10-accessibility.md binds
/// its ≥ 4.5:1 rule to: text does sit on it. That runtime contrast computation (a hex background's
/// text colour, recomputed against the composited backdrop on appearance change) is not this
/// card's — what ships here is the palette path, whose twelve pairs are AA-verified at design
/// time.
///
/// A value that resolves to nothing paints nothing, so the window keeps the standard background:
/// the same lenient degrade as the other two levels, and the bytes stay as written.
@ViewBuilder
private var boardBackground: some View {
if let color = Palette.color(for: store.snapshot.background) {
color
}
}
// 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
/// interior gaps it spans). While THIS lane is being resized the slot holds two layers, kept
/// structurally stable so the `LaneView` never loses identity — its scroll position, its
/// masonry cache — across the drag:
///
/// • a shadow at the SNAPPED slot width, full strip height, behind the lane — the resting
/// footprint the siblings and the window are already aligned to;
/// • the live `LaneView` in front at `liveWidth`, which tracks the cursor and so overflows
/// (drawing over the right neighbour, hence the slot's `zIndex(1)`) or underfills the shadow
/// between ticks.
///
/// 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, 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) {
if resizing {
LaneResizeShadow()
.frame(width: slotWidth)
.frame(maxHeight: .infinity)
.allowsHitTesting(false)
}
// Interior columns follow the SNAPPED unit count while this lane is being resized — a
// column count is integral, so it tracks k (which ticks and animates), not the live
// 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(
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)
}
// The drop highlight for a drag out of the trash: the lane the pointer is currently over.
// Feedback lives on the *target* rather than on a travelling replica, because the replica —
// its lift, its settle, the copy/move badge — is m5's drag card (03-board-ui.md § Motion).
.overlay {
if trashDrag.isTarget(lane.id) {
RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color.accentColor, lineWidth: 2)
.allowsHitTesting(false)
}
}
.frame(width: slotWidth, alignment: .topLeading)
.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,
session: resize,
laneID: lane.id,
committedUnits: LaneLayoutMath.displayUnits(of: lane),
standard: standard,
gap: spacing,
window: window
)
// The read-only lock disables every mutating gesture, not just the menu items
// (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. 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)
}
}
/// The lanes the strip lays out, in snapshot order. **Tombstoned lanes render nowhere here** —
/// 03-board-ui.md § Trash collapses each into a single restorable entry in the trash quasi-lane
/// (a later card), and a lane that is not on the board consumes none of the window's width.
private var liveLanes: [Lane] {
store.snapshot.lanes.filter { !$0.isDeleted }
}
// MARK: - Trash
/// Whether the trash quasi-lane is on screen — transient, board-scoped, hidden on every open
/// (03-board-ui.md § Trash ▸ Visibility). Read in two places (the unit total and the slot), so it
/// gets a name rather than being spelled twice.
private var isTrashVisible: Bool {
store.transient.isTrashVisible
}
/// The live lane under `x` in strip coordinates, or `nil` — the strip's half of drag-to-restore.
///
/// Re-derived against `liveLanes` at gesture time rather than captured at drag start, which is
/// 04-interactions.md ▸ Drag and drop's re-grounding rule: a foreign reload that adds or
/// tombstones a lane mid-drag just moves the zones, and the next proposal targets the board as it
/// now is. A tombstoned lane is never a drop target because it is never in this list.
private func laneUnder(x: CGFloat, standard: CGFloat) -> ItemID? {
let lanes = liveLanes
guard let index = LaneLayoutMath.laneIndex(
atX: x,
unitCounts: unitCounts(of: lanes),
standard: standard,
gap: spacing
) else { return nil }
return lanes.indices.contains(index) ? lanes[index].id : nil
}
// MARK: - Reorder
/// 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.
///
/// Called **once** per render, because a proposal is two things at once and they must be the
/// same answer: the order the strip shows, and the narrow key its reflow animates on (see
/// `body`). It is asked again at release, against the snapshot as it is by then
/// (`commitReorder`).
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
}
/// **Plain ⌫ tombstones the live selection** — "a plain-key synonym of File ▸ Delete, kept
/// grammar so no second 'Delete' title exists" (11-command-nexus.md; 04-interactions.md ▸ The
/// map).
///
/// Deliberately **live-only**: the nexus scopes this key to a live selection, and ⌫'s trash-side
/// role belongs to the ⌘⌫ twins, not to the bare key. A tombstoned selection is therefore inert
/// here — Put Back is a chord.
///
/// Inert while an inline editor is open, like every grammar key: the field owns ⌫ as backspace,
/// and a stray one reaching the board mid-edit would delete the item being renamed.
private func handleDelete(_ press: KeyPress) -> KeyPress.Result {
// **Plain ⌫, spelled out.** The modified chords belong to the menu — ⌘⌫ (Delete / Put Back),
// ⌥⌘⌫ (Delete Immediately), ⇧⌘⌫ (Empty Trash…) — and AppKit routes a key equivalent to the
// menu before the view sees it. But ⌥⌫ and ⌃⌫ are nobody's key equivalent, and a fall-through
// that tombstoned the selection on a mistyped text-editing chord would be exactly the kind of
// accident 04-interactions.md's fixed grammar is careful to avoid.
guard press.modifiers.intersection([.command, .option, .control, .shift]).isEmpty else {
return .ignored
}
guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
let selection = store.selection
guard selection.liveness == .live, !selection.isEmpty else { return .ignored }
store.deleteSelection()
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
/// The resting footprint a lane snaps back to, drawn behind the live lane during a resize.
///
/// Minimal on purpose: 03-board-ui.md's placeholder/drag vocabulary lands with the drag milestone,
/// and this is the same shape that card and lane drops will want. Kept here rather than invented
/// twice.
private struct LaneResizeShadow: View {
var body: some View {
RoundedRectangle(cornerRadius: 10)
.fill(.quaternary.opacity(0.5))
}
}