Files
lanework/Kanban/UI/Board/BoardView.swift
T
rzen 8aefaf23ce The empty provider was never load-bearing — the dragless layer frees the rubber band
Measured on real events 2026-08-07, correcting the 2026-08-06 hosted
finding: a bare count-1 tap on LaneView's empty-space layer fires in
~1-3 ms with no drag source at all — the hold that made the empty
.onDrag look necessary was the sterile NSApp.postEvent stream
over-disambiguating. And the provider was actively harmful: even an
empty drag source claims the mouse-drag at threshold, starving the
marquee's simultaneous DragGesture after one sample — the band froze
and the mouseUp never arrived. The layer goes dragless; drags from
empty space belong wholly to MarqueeControl. PointerClick's and the
layer's comments retell the corrected story. Alongside: openCard is
typed @MainActor throughout, which makes the closure Sendable and
lets CardFaceRole carry it under CardFaceView's nonisolated ==.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-07 12:55:54 -04:00

1245 lines
72 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import SwiftUI
/// 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). Deliberately *not* a drag session
/// (DRAG-REORDER.md § Adjacent interaction).
/// - **Drag & drop** — cards (in either container) and lanes travel as **system drag sessions**, which is what
/// crosses window boundaries, draws the copy badge and gives the full-size replica
/// (`DragSession`, `BoardDrops.swift`, DRAG-REORDER.md). The strip owns the drop geometry
/// registry and the strip-level drop target; the lanes own theirs.
/// - **The rubber band** — a drag from any empty surface sweeps a selection (`MarqueeSession`,
/// `MarqueeMath`); the strip owns the session and the target registry, and hands both down.
/// - **The board's fixed grammar keys** (11-command-nexus.md ▸ Fixed grammar keys) — the four
/// arrows and their ⇧/⌥ modes, Return's create/rename dispatch, ⌫'s staged delete, Escape's step
/// outward, and Select All. The chorded commands are the menu's (`BoardCommands`); everything
/// here is a plain key or a grammar modifier, which is exactly the split 04-interactions.md ▸
/// Configurable bindings draws between what remaps and what does not.
/// - **The standard Edit items the board answers as a responder** — Select All, and Cut/Copy/Paste
/// beside it (`ClipboardCommands.swift`). They are not menu items of ours: the Edit menu already
/// carries those titles, and titles are the remapping mechanism's key, so a second row sharing one
/// is ruled out (04-interactions.md ▸ Configurable bindings).
///
/// - **The trash column** — trailing, one fixed unit, joining and leaving the width division as
/// View ▸ Show Trash toggles it (`TrashLaneView`, 03-board-ui.md § Trash).
///
/// ### The live search filter
///
/// The filter itself is a pure predicate (`SearchFilter`) and the field is the toolbar's
/// (`BoardSearchField`, installed by `BoardWindowHost`); what belongs to this file is the two places
/// the board *reads* it — the arrow grammar's order lists and jump containers, so navigation walks
/// the filtered board, and Escape's middle step. Everything else follows from `LaneView`'s and
/// `TrashLaneView`'s own narrowing, because the drop zones, the marquee and the file-drop targets
/// all read what those two rendered.
///
/// ### What is deliberately not here yet
///
/// External Finder file drops join the very drop delegates this file already attaches (see
/// `BoardDrops.swift`).
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: @MainActor (ItemID) -> Void
/// The toolbar search field's handle (`BoardSearchPresentation`), threaded down so the strip can
/// fill in `focusBoard` — Escape's "in an empty field it returns focus to the board" needs the
/// strip's own `@FocusState`, which nothing outside this view can reach.
let search: BoardSearchPresentation
/// 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()
/// Where this window's lanes draw their card grids and how tall their cards are — the measured
/// half of a card drop's geometry, plus the strip's own frame (`LaneDropRegistry`). `@State` for
/// the resize session's reason: one per window, living exactly as long as the window.
@State private var laneDrops = LaneDropRegistry()
/// One rubber band at a time, per window (`MarqueeSession`).
@State private var marquee = MarqueeSession()
/// Where every sweepable item is drawn, in strip coordinates. Owned here because the band is —
/// the card faces on either side only *register* into it (`MarqueeTargetRegistry`).
@State private var marqueeTargets = MarqueeTargetRegistry()
/// 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
/// Increase Contrast, for the marquee band's border below (10-accessibility.md; `Accommodations`).
@Environment(\.colorSchemeContrast) private var contrast
/// Reduce Transparency, for the backdrop's title-bar frost — the one glass underlay this view
/// draws (10-accessibility.md: "glass underlays go solid, wherever they appear";
/// `Accommodations.frost`).
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
/// **The board's ruler** (03-board-ui.md ▸ Layout — zoom; `BoardZoom`). Injected on this view by
/// `BoardWindowHost` and read all the way down the strip; every `BoardMetrics` figure below takes
/// `zoom.bodyPointSize` rather than the system's, which is the whole of what View ▸ Zoom In does.
@Environment(\.boardZoom) private var zoom
/// 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`).
///
/// **Font-derived** (`BoardMetrics.stripGap`), which is what keeps the no-horizontal-scroll
/// invariant honest under 10-accessibility.md's full-relative-scaling rule: a larger system text
/// size widens the gap and therefore *narrows* every lane, since the window's width still divides
/// across `units + 1` gaps. The strip never grows and never scrolls; the lanes compress, "the
/// degenerate case accepted, not floored" (03-board-ui.md § Layout — full visibility).
private var spacing: CGFloat { BoardMetrics.stripGap(bodyPointSize: zoom.bodyPointSize) }
var body: some View {
// The strip's own body count (`BoardRenderMetrics`) — DEBUG only, and the discriminator
// between "the gate did not suppress" and "Observation invalidated the lane directly".
#if DEBUG
let _ = BoardRenderMetrics.countStripBody()
#endif
GeometryReader { proxy in
// The strip's slots: the lanes the strip should *show*, with the drag's N contiguous
// shadows opened at the proposal. 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) — nothing about a drag is cached across a snapshot.
let slots = stripSlots
ZStack(alignment: .topLeading) {
backdrop
laneStrip(slots, standard: standardWidth(stripWidth: proxy.size.width))
}
.padding(spacing)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
// The band, drawn **outside the padding** so its offset is a strip coordinate directly —
// and outside every animated modifier above, because 03-board-ui.md § Motion puts the
// marquee in the animation-free-by-construction list ("1:1 cursor following — animating
// input echo would be lag").
.overlay(alignment: .topLeading) { marqueeBand }
// The space the marquee 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. Every marquee coordinate —
// the band's own drag samples and each registered item frame — is measured here too, so
// nothing ever converts between spaces.
.coordinateSpace(.named(Self.stripSpace))
// The same rectangle in the window's *global* space, which is where the physical cursor
// lands once converted (`BoardDropContext.globalCursor`). Written into the registry
// rather than into `@State` so the drop delegates read it live at event time rather than
// as of the last body evaluation.
.onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { laneDrops.stripFrame = $0 }
// The board's ruler, into the registry beside the strip's frame and for its reason: the
// drop delegates run at event time, outside any body, and `nominalCardHeight` — the
// stand-in they tile un-measured rows with — has to be computed on the zoom the board is
// actually drawing at (03-board-ui.md ▸ Layout — zoom). `initial: true` because the first
// render is already a level, not a change.
.onChange(of: zoom.bodyPointSize, initial: true) {
laneDrops.bodyPointSize = zoom.bodyPointSize
// And the resting layouts built on the old ruler go with it. `RestingLayoutCache`'s
// entry key is snapshot generation, heights generation, board root and hidden set —
// deliberately not the point size, because until zoom existed the point size could
// not move. This is what keeps that key honest rather than adding a fifth term to it:
// a level change is rare, a cache miss costs one rebuild, and `ZoomCommands` already
// holds the rows shut while a drag is in flight, so in practice there is nothing
// standing here to clear.
dropContext.session.restingLayouts.clear()
}
// **The strip's drop target** — the backdrop, the gaps, the outer margin, and the trash
// column's footprint, which is never a landing spot of its own (04-interactions.md ▸ The
// trash) and so simply falls through to here. It accepts *every* session type — ours and
// external Finder file drags alike — and routes internally, because single-target
// dispatch has no fall-through (DRAG-REORDER.md).
.onDrop(of: boardDropTypes, delegate: StripDropDelegate(context: dropContext))
}
.background(boardBackground)
// The window-level fallback, *behind* the specific targets: a release over any in-window
// region they don't cover commits the current proposal instead of leaking the session into a
// cancel-snapback — the drop lands where the shadows show, which is what the shadows promise.
.background {
Color.clear
.onDrop(of: boardDropTypes, delegate: BoardFallbackDropDelegate(context: dropContext))
}
// **The committed-overlay hold's hand-off** (DRAG-REORDER.md § The committed-overlay hold):
// the overlay stands in for an arrangement that is on disk but not yet in the snapshot, and
// discards itself the moment a snapshot lands — because holding a moment longer would draw
// the arrangement twice.
.onChange(of: store.snapshotGeneration) { _, generation in
appModel.dragSession.handOff(root: store.rootKey, generation: generation)
}
// **The lane-resize release hold's hand-off** (`LaneWidthHold`), the same shape one rung
// narrower: that overlay stands in for an *arrangement* and any landing retires it, this one
// for a *width* and only a landing that carries it will do — an unrelated reload arriving
// first must not hand the strip back to a snapshot that still says the old unit count.
//
// **`landedReloads`, not `snapshotGeneration`**: the question is "is the width current",
// asked on every walk that landed, so a reload whose tree came back value-equal — which
// skips the assignment and moves no generation — still gets to answer it.
.onChange(of: store.landedReloads) { _, _ in
resize.handOff(against: store.snapshot.lanes)
}
// **The stationary modifier flip's re-proposal** (`BoardDropContext.retargetAfterModifierFlip`).
// Drop callbacks arrive only while the mouse moves, so a ⌥ pressed against a still pointer
// reaches the drag through the session's `.flagsChanged` watch instead — and lands back on
// the one shared retarget here, in the board window whose surface resolved the proposal.
//
// Every open board window carries this handler and exactly one of them acts: the flip
// replays against a `LaneDropRegistry`, which is this window's alone.
//
// **No transaction of its own**, deliberately: a flip must produce exactly what the next
// mouse sample would have produced, and `dropUpdated` wraps nothing either. What moves is
// keyed declaratively where it is drawn — the strip on its proposal, each lane on its shadow
// run (03-board-ui.md § Motion, transactions keyed narrowly).
.onChange(of: appModel.dragSession.modifierGeneration) { _, _ in
dropContext.retargetAfterModifierFlip()
}
.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 one tab stop** (10-accessibility.md ▸ Full Keyboard Access: "the board is
// one tab stop with arrow-key navigation within"), and this is it: one focusable strip,
// whose interior movement is the arrow grammar below rather than a focus stop per card.
// The controls *around* the cards — each lane's new-card button, the popovers, the toolbar —
// are ordinary `Button`s and stay Tab-reachable in their own right, which is the other half
// of the same sentence ("every control … is Tab-reachable").
//
// The focus *ring* is normally off: the strip is the window's content, not a control, and a
// rectangle around the whole board would read as an error state. **Under Full Keyboard
// Access it comes back**, because that reasoning inverts completely there — FKA's premise is
// that the user can see where Tab landed, and an invisible tab stop is not one
// (`Accommodations.isFullKeyboardAccessEnabled`).
.focusable()
.focusEffectDisabled(!Accommodations.isFullKeyboardAccessEnabled)
.focused($isBoardFocused)
.onAppear {
isBoardFocused = true
// **Escape's second step, wired from the side that can perform it** (04 § Search): the
// field can resign first responder on its own, but only the strip can *take* the
// keyboard, and a window with a resigned field and an unfocused board would swallow
// every grammar key. `@FocusState`'s setter is nonmutating, so the closure writes the
// same storage this view reads.
search.focusBoard = { 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(keys: [.return], phases: .down) { handleReturn($0) }
.onKeyPress(.escape) { handleEscape() }
.onKeyPress(keys: [.delete], phases: .down) { handleDelete($0) }
// **The arrows** (04-interactions.md ▸ Grammar), on `.down` *and* `.repeat`: holding an
// arrow must walk the board, and a handler registered for `.down` alone sees the first
// press only.
.onKeyPress(
keys: [.upArrow, .downArrow, .leftArrow, .rightArrow],
phases: [.down, .repeat]
) { handleArrow($0) }
// **Select All** (04-interactions.md ▸ The map). Edit ▸ Select All is the standard menu
// item and it dispatches `selectAll:` down the responder chain, so the board answers it as a
// responder rather than growing a second menu item with the same title — which titles-are-API
// forbids outright (04 ▸ Configurable bindings). A focused text field consumes it first, so
// ⌘A inside an inline editor stays text selection with no guard needed here.
.onCommand(#selector(NSText.selectAll(_:))) { store.selectAll() }
// **Cut / Copy / Paste** (04-interactions.md ▸ Clipboard), through the same responder door
// Select All above uses and for the same titles-are-API reason. Each handler is attached only
// while its command applies, which is what makes AppKit's automatic enablement mirror the
// validation exactly — see `boardClipboardCommands`.
.boardClipboardCommands(store: store, clipboard: appModel.clipboard)
// **Belt** for a session SwiftUI does report ending: immediate cleanup, gated on the physical
// mouse button being up. A finished session's phase events can arrive *after* the user has
// started the next drag, and an ungated handler would wipe the new session's state — no
// shadow, drop dead. `DragSession`'s watchdog is the braces (see `armWatchdog`), and it is
// what makes refusing here free.
.onDragSessionUpdated { session in
switch session.phase {
case .ended, .dataTransferCompleted:
MainActor.assumeIsolated { appModel.dragSession.endIfButtonReleased() }
default:
break
}
}
}
// MARK: - The strip's layers
/// The empty surface behind the lanes: a plain click clears the selection, a drag rubber-bands.
///
/// `Color.clear` with a `contentShape` rather than a real fill — the board's *painted*
/// background is `boardBackground`, outside the geometry reader, and this layer exists only to
/// be hit. Modified clicks are deliberately no-ops: ⌘ and ⇧ on the backdrop name no target, and
/// Finder's own desktop behaves the same way.
private var backdrop: some View {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
guard ClickModifier.current == .plain else { return }
store.clearSelection()
}
.simultaneousGesture(marqueeControl.gesture(in: .board))
}
/// The lanes and the drag's shadows, plus the trash column when it is shown.
@ViewBuilder
private func laneStrip(_ slots: [StripSlot], standard: CGFloat) -> some View {
HStack(alignment: .top, spacing: spacing) {
ForEach(Array(slots.enumerated()), id: \.element.id) { index, slot in
switch slot {
case let .lane(lane):
laneSlot(lane, standard: standard)
// "Appear/disappear is scale + fade … lanes ~0.9" (03-board-ui.md § Motion).
// A create, a delete and an undo 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))
// **Lanes are read in lane `order`** (10-accessibility.md ▸ The board
// through VoiceOver). Geometry already agrees — an `HStack` lays the slots
// out left to right in this very sequence — so unlike the masonry's
// column-major divergence (`LaneView`) this is a statement rather than a
// correction. It is written anyway for what it buys below: the priorities
// stay above the trash's, which is the only way "the trash is the LAST
// container" survives a right-to-left layout direction or a lane slot the
// resize session lifts to `zIndex(1)`.
.accessibilitySortPriority(Double(slots.count - index))
case let .shadow(_, units):
// One of the drag's N contiguous shadows, at the exact width the arriving lane
// will occupy — its units measured against *this* strip's standard, which is
// what makes the drop land precisely where the shadow shows.
DragShadow(cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: zoom.bodyPointSize))
.frame(width: LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing))
.frame(maxHeight: .infinity)
}
}
if isTrashVisible {
// Trailing, always — the column has no position of its own to lose, which is
// also why it never appears in the drop proposal's inputs (those are built from
// `boardLanes`) and why the terminal slot clamps in front of it.
TrashLaneView(
store: store,
confirmations: confirmations,
drops: dropContext,
marquee: marqueeControl
)
.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))
// "When shown, it is the **last** container" (10-accessibility.md ▸ Trash lane) —
// below every lane's priority, whatever the lane count, because zero is the floor
// the expression above never reaches.
.accessibilitySortPriority(0)
}
}
// 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"). Narrowed further to the *strip's* proposal: a card
// session moving its shadow inside a lane must not re-time the whole strip. The replica's
// own tracking is the system drag image's and touches nothing here, which is the same
// bullet's other half — animating input echo would be lag.
//
// It stays on the `HStack` rather than moving out to the `ZStack`, so the marquee band
// drawn beside it is never inside an animated transaction (03 § Motion again).
.animation(Motion.dragReflow(reduced: reduceMotion), value: stripProposal)
// **The search filter's reflow**, keyed on **the query** and nothing else — 03-board-ui.md
// § Motion names it in the narrow-keys list ("on the search query (filter reflow)") — and in
// the *content* voice rather than the structural one: "search filtering and undo/redo
// restore, deliberately paired so a restore reads like the search filter — leavers and
// arrivers run their transition, survivors reflow under one gentle spring". The leavers and
// arrivers are the card and row transitions already attached inside the lanes and the trash
// column; this is the survivors' spring around them.
//
// **Every way the filter changes rides it**, which is the reason the key is the filter rather
// than the transaction being wrapped at each mutation: typing, the field's Escape, the
// board's Escape, and creation's clear (`TransientBoardState.beginPlaceholder`) all land
// here without any of them knowing about motion.
//
// The *filter*, not the query, since comments joined the search's scope (04 ▸ Search, re-ruled
// 2026-07-29): a landed comment-index sweep widens what matches without the query changing at
// all, and keying on the query alone would make those cards pop in while every other arrival
// eased. `SearchFilter` is `Equatable` and carries the index' answer, so one key covers both.
.animation(Motion.contentReflow(reduced: reduceMotion), value: store.searchFilter)
}
/// The rubber band itself: a translucent accent fill with a hairline border, in strip
/// coordinates and **never animated** (03-board-ui.md § Motion — the marquee "tracks the cursor
/// 1:1", and an eased band visibly lags the mouse).
///
/// Hit-testing off, because the band is feedback: the drag that draws it is already recognised,
/// and a rectangle that swallowed clicks would eat the release.
@ViewBuilder
private var marqueeBand: some View {
if let rect = marquee.rect {
Rectangle()
.fill(Color.accentColor.opacity(0.12))
.frame(width: rect.width, height: rect.height)
// Increase Contrast takes the band's edge to full strength and a point heavier: the
// fill is a 12%-alpha wash by design, so the border is the only thing that says where
// the sweep actually reaches (`Accommodations`).
.overlay(
Rectangle().strokeBorder(
Color.accentColor.opacity(Accommodations.accentOpacity(0.5, contrast: contrast)),
lineWidth: Accommodations.borderWidth(1, contrast: contrast)
)
)
.offset(x: rect.minX, y: rect.minY)
.allowsHitTesting(false)
}
}
/// What the strip lends its empty surfaces and its sweepable items — the band's session, the
/// registry, and the store it selects into (`MarqueeControl`).
private var marqueeControl: MarqueeControl {
MarqueeControl(session: marquee, registry: marqueeTargets, store: store)
}
// 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.
///
/// ### Three layers, and the window is the frame
///
/// The colour is the underlay, the image draws over it, and a frosted strip sits on top of both
/// under the title bar. All of it runs the **full height of the window** — `ignoresSafeArea`
/// here is what the window's own `fullSizeContentView` flip is for (`HostedWindowController
/// .setExtendsContentUnderTitlebar`, driven from `BoardWindowHost`), and the two only ever move
/// together: a board with no background of its own draws none of this and keeps the standard
/// chrome exactly as it has always looked.
///
/// The **colour is painted even while an image is loading**, and stays painted underneath it: a
/// decode is asynchronous (`BoardBackdropImage`) and a window that flashed the system background
/// on open would be the hitch that work exists to avoid. It is also what a failed or missing
/// image degrades to, with nothing said about it.
///
/// The **frost** is the price of the extended chrome: the title bar's own material is gone, so
/// the traffic lights, the board-name widget and the toolbar would otherwise sit directly on a
/// saturated colour or a photograph. It is a glass underlay (`Accommodations.frost` — the
/// ladder's thin weight, both ends tried and retired: `.bar` reads as barely-there over a
/// busy image, `.regular` and up as a veil the backdrop shouldn't pay for) and takes the
/// standard accommodation: solid
/// under Reduce Transparency, "wherever they appear". Its geometry is `frostStrip`'s — full
/// strength through the top safe-area inset, dissolving over a short tail below it — with the
/// inset read from a `GeometryReader` that is itself inside the `ignoresSafeArea`: the proxy
/// still reports the inset it was told to ignore, which is exactly the title-bar-plus-toolbar
/// band and moves on its own when the toolbar's size class changes. Nothing here hit-tests, so
/// the widget and the toolbar above it are untouched.
///
/// ### The contrast rule is the colour's, and the image is outside it
///
/// The rule is enforced from the *text* side rather than here, because this view paints the
/// surface and draws none of the glyphs on it. Whatever colour lands below — a palette name, a
/// hand-written hex, or the `color` subkey of the mapping form, they reach the same place — has
/// its text colour computed against the threshold by `BoardTextInk`, composited over the window
/// background in the active appearance and recomputed on an appearance flip; the two subtrees
/// that sit on this fill (`LaneView.header` and `TrashLaneView.header` — their plates are
/// translucent washes the colour shows through, where every card carries its own opaque plate,
/// `BoardSurface.cardPlate`) take the answer as a `\.colorScheme` override.
///
/// **One path, two verification stories** (`ContrastMath`): the twelve palette pairs are checked
/// statically, by a test over the ink this seam chooses for each of them (03-board-ui.md §
/// Styling ▸ Controls' "AA-verified at design time", which is a claim about the *pair* and so
/// cannot be settled by a table of colours alone); an arbitrary hex is checked only as it
/// renders, because its value arrives from a file.
///
/// **An image makes no AA claim at all**, and the ink does not try to derive one from it. Ink
/// still follows the `color` reading — the colour the author chose to sit under the picture, or
/// the default when they chose none — which is the same bytes-from-a-file posture an arbitrary
/// hex already has, one step further out: a photograph has no single luminance to threshold
/// against, the field has no in-app control that could warn about one, and a per-pixel answer
/// would change as the window resized. An author who lays text over a busy picture is doing what
/// the raw file exists to let them do.
///
/// 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 {
let color = Palette.color(for: store.snapshot.background)
let image = BoardBackdrop.imageURL(for: store.snapshot, root: store.rootURL)
if color != nil || image != nil {
GeometryReader { proxy in
ZStack(alignment: .top) {
color
if let image {
BoardBackdropImage(url: image, reloads: store.landedReloads)
}
frostStrip(inset: proxy.safeAreaInsets.top)
}
}
.ignoresSafeArea()
// A background is scenery: the strip's own empty-surface gestures — the click that
// clears the selection, the rubber band — live in `backdrop`, one layer in, and would be
// swallowed by anything here that answered a hit test.
.allowsHitTesting(false)
}
}
/// The frost, full-strength through the title-bar band and dissolving over a short tail below
/// it — a scroll-edge dissolve rather than a shelf. The chrome sits on an even material the
/// whole way down, and the strip's bottom edge is nowhere in particular, so the backdrop reads
/// as one surface the chrome floats over rather than a bar laid across a picture. The tail is a
/// fraction of the band, so it scales with the toolbar's own height and only ever reaches into
/// the strip's outer padding, not the lanes.
///
/// Under Reduce Transparency the fade goes with the glass: "solid" means an honest opaque bar
/// with the standard chrome's own hard edge (`Accommodations.frost`), not a solid that thins
/// out — a partially transparent solid would be the setting's own defeat.
@ViewBuilder
private func frostStrip(inset: CGFloat) -> some View {
let underlay = Accommodations.frost(reduceTransparency: reduceTransparency)
if underlay == .solid {
Rectangle().fill(underlay.style).frame(height: inset)
} else if inset > 0 {
let tail = inset * 0.35
Rectangle()
.fill(underlay.style)
.frame(height: inset + tail)
.mask {
LinearGradient(
stops: [
.init(color: .black, location: 0),
.init(color: .black, location: inset / (inset + tail)),
.init(color: .clear, location: 1),
],
startPoint: .top,
endPoint: .bottom
)
}
}
}
// 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.
///
/// A lane being **dragged as a move** is simply absent from the strip: it is lifted out of the
/// resting layout at pickup and stays out until release, while the system drag session carries
/// its replica. A lane being dragged as a **copy** stays in the strip instead, because the copy
/// leaves it there (DRAG-REORDER.md § Resting-layout zones) — which on this board means the
/// cross-board case alone, ⌥ being ignored by a within-board lane drag.
@ViewBuilder
private func laneSlot(_ lane: Lane, standard: CGFloat) -> some View {
// **The session governs through the release**, not just the drag: after the release its
// frozen standard and the unit count it *wrote* keep answering here until the snapshot
// carries that width back (`LaneWidthHold`). Reading `lane.width` in that window would draw
// the pre-drag layout for a round trip.
let resizing = resize.governs(lane.id)
let units = resize.displayUnits(of: lane)
let slotWidth = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing)
ZStack(alignment: .topLeading) {
if resizing {
DragShadow(
cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: zoom.bodyPointSize),
dashed: false
)
.frame(width: slotWidth)
.frame(maxHeight: .infinity)
}
// 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,
slotWidth: slotWidth,
drops: dropContext,
marquee: marqueeControl,
openCard: openCard
)
// **The value gate** (`LaneView.==`): this body re-runs on every drop-proposal change,
// and without this every lane on the board — and every card face in it — rebuilds on
// every cursor move of every drag.
.equatable()
.frame(width: resizing ? resize.liveWidth : slotWidth, alignment: .leading)
}
.frame(width: slotWidth, alignment: .topLeading)
.zIndex(resizing ? 1 : 0)
.overlay(alignment: .trailing) {
LaneResizeHandle(
store: store,
session: resize,
laneID: lane.id,
committedUnits: units,
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 drag session in flight — the edge drag "refuses to start while a card/lane
// session is in flight" (DRAG-REORDER.md § Adjacent interaction): two gestures mutating
// one strip layout is not a state this view has a meaning for.
.disabled(store.isReadOnly || store.isEditingInline || appModel.dragSession.isActive)
}
}
/// The lanes the strip lays out, in snapshot order — every lane the board has. Deleting a lane
/// is physical now (03-board-ui.md § Trash: "Cards only. Lanes are never trashed"), so a lane in
/// the snapshot is a lane on the board, with no hidden state to filter for.
private var boardLanes: [Lane] {
store.snapshot.lanes
}
// MARK: - Trash
/// Whether the trash column 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 trash's **rows** as the column is showing them, both kinds in rank order — the shown
/// trash's contents "participate in the filter exactly like any other card" (03-board-ui.md §
/// Trash), and the arrows walk what is on screen (`TrashLaneView` applies the identical predicate
/// to the identical rows).
///
/// **Rows and not cards, because this is navigation** (04-interactions.md ▸ The trash: "inside,
/// plain arrows walk every row, card and lane row alike (navigation crosses kinds)", and ▸
/// Grammar gives ⌥→ "the shown non-empty trash — its first *entry*"). The kind-scoped lists are
/// the *ranging* grammar's (`SelectionGrammar.order`), which is what stops a ⇧-arrow at the kind
/// boundary while a plain one crosses it.
///
/// Read by the three keyboard destinations that reach into the column — the arrow origin's
/// order list, ⌥↑/⌥↓'s container, and ⌥→'s jump — so none of them can walk onto a row the
/// filter took away.
private var trashRows: [ItemID] {
SelectionGrammar.trashRows(in: store.snapshot, filter: store.searchFilter)
}
// MARK: - The drag
/// What each of this window's drop targets — and its lanes' autoscroll drivers — is handed.
///
/// Every geometric input is a **closure**, read at event time (`BoardDropContext`): a captured
/// snapshot of the strip's frame or its standard width goes stale the moment the layout animates,
/// and two delegates holding different snapshots would flap the proposal between them.
private var dropContext: BoardDropContext {
BoardDropContext(
store: store,
session: appModel.dragSession,
registry: laneDrops,
gap: spacing,
window: window,
stripFrame: { laneDrops.stripFrame },
standard: { standardWidth(stripWidth: laneDrops.stripFrame.width) }
)
}
/// The strip's 1× lane width, for a strip `stripWidth` points wide.
///
/// **The width is a parameter, and which width the caller passes matters.** Body evaluation
/// passes the `GeometryReader`'s live proposal, because that is the one source that invalidates
/// the view when it changes — the registry's `stripFrame` is deliberately unobserved (it exists
/// for event-time reads), so a body computed from it renders the *previous* layout's width: on
/// first open that is `.zero`, every lane collapses to `standardWidth`'s 1pt floor, and the
/// board stays that way until an unrelated invalidation happens by. The drop delegates pass
/// `laneDrops.stripFrame.width` instead, which is the same number read at event time — exactly
/// the registry's purpose. The two agree because the padded container fills the reader.
///
/// 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 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 formula yields once the session ends — the handoff is seamless (see
/// `LaneResizeSession`).
///
/// **"Once the session ends" is the echo, not the release** (`LaneWidthHold`). The equality that
/// makes the handoff seamless is between the frozen standard and the formula run over the *new*
/// unit total, and the write takes a watcher round trip to put that total in the snapshot;
/// dividing the already-grown window by the stale total in between is exactly the two-step the
/// hold exists to remove.
///
/// Otherwise it is the ordinary division, over three contributions:
///
/// - the **live lanes**' units. A lane in flight is still a lane on the board, so a within-board
/// drag does *not* re-divide the strip: "the shadow occupies its units" (DRAG-REORDER.md § The
/// lane strip's resting layout is arithmetic). Recomputing at pickup and again at release is
/// exactly the motion-feeds-back-into-logic failure the model exists to avoid.
/// - the **trash's** one fixed unit, *only while shown* — 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 **cross-board lane arrival**'s units while its shadow hovers here, by the same rule read
/// from the destination's side: the shadow occupies its units, and the strip has to make room
/// for them or the shadow would be drawn at a width the lane will not have
/// (`arrivingLaneUnits`).
private func standardWidth(stripWidth: CGFloat) -> CGFloat {
if resize.isActive { return resize.standard }
var units = LaneLayoutMath.totalUnits(of: boardLanes, trashUnits: isTrashVisible ? 1 : 0)
units += arrivingLaneUnits
return LaneLayoutMath.standardWidth(
stripWidth: stripWidth,
totalUnits: units,
gap: spacing
)
}
/// The units a cross-board lane run would add to this strip while its shadow is proposed here;
/// zero for a within-board drag, whose lanes are already counted.
///
/// **A cross-board lane arrival pre-divides the destination strip during hover**
/// (04-interactions.md ▸ Drag and drop, settled): "while a foreign lane drag proposes into a
/// board, the destination's standard width is computed with the arriving run's units included, so
/// the shadow draws at the width the lane will actually take — without this it overflows the
/// strip (the pathfinder's `stripWidthUnits`)". The units are the run's own, frozen at pickup by
/// the *source* board (`DragSession.laneUnits`) — both boards are open in this app, so nothing
/// has to cross the pasteboard for the destination to know how wide its visitor is.
///
/// Gated on a live proposal *on this board* rather than on hover alone, which is the same bullet's
/// accepted residue: "the first entry samples the un-widened standard for one frame before
/// hysteresis settles — accepted, imperceptible".
///
/// One number, two readers: body evaluation draws the strip and the shadow with it
/// (`standardWidth`), and the drop delegates retarget against it through `dropContext.standard`,
/// so the zones can never disagree with what is on screen.
private var arrivingLaneUnits: Int {
let session = appModel.dragSession
guard session.isDraggingLanes,
session.stripProposal(onBoardRooted: store.rootKey) != nil,
let source = session.sourceRoot,
source != store.rootKey
else { return 0 }
return session.laneUnits.reduce(0, +)
}
/// The strip's current lane-drop proposal — the reflow's narrow animation key, and where the
/// shadow run opens.
private var stripProposal: Int? {
appModel.dragSession.stripProposal(onBoardRooted: store.rootKey)
}
/// One position in the strip: a lane, or one of the drag's N contiguous shadows.
private enum StripSlot: Identifiable {
case lane(Lane)
case shadow(index: Int, units: Int)
var id: String {
switch self {
case let .lane(lane): "lane:\(lane.id.rawValue)"
// Constant per position, so a shadow run keeps its identity as the proposal slides and
// the run animates as a move rather than blinking out and back in.
case let .shadow(index, _): "shadow:\(index)"
}
}
}
/// What the strip lays out: the resting lanes — the dragged run lifted out when the release
/// would be a move, left standing when it would be a copy (`DragSession.hiddenMembers`) — with
/// the shadows opened at the proposal.
private var stripSlots: [StripSlot] {
let session = appModel.dragSession
let hidden = session.hiddenMembers(onBoardRooted: store.rootKey)
var slots = boardLanes.filter { !hidden.contains($0.id) }.map(StripSlot.lane)
guard let index = stripProposal else { return slots }
let shadows = session.laneUnits.enumerated().map { StripSlot.shadow(index: $0.offset, units: $0.element) }
slots.insert(contentsOf: shadows, at: min(max(0, index), slots.count))
return slots
}
// 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.
///
/// 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(_ press: KeyPress) -> KeyPress.Result {
// **Plain Return only**, the delete handler's rule for its reason. ⌘↩ belongs to Board ▸
// Open Card and AppKit routes it to the menu first — but only while that item is *enabled*,
// and a disabled one lets the chord fall through to here. ⌥↩ and ⇧↩ are nobody's key
// equivalent at all. Neither may open a rename or a placeholder.
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.container == .board,
selection.ids.count == 1,
let id = selection.ids.first,
let target = BoardStore.boardItem(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 ⌫ deletes the 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).
///
/// **Both stagings**, unlike the tombstone era's live-only reading: "Plain ⌫ performs the same
/// delete as fixed grammar" (04-interactions.md ▸ The map, resettled 2026-07-28) — a board
/// selection moves into `.trash/`, a trash selection deletes permanently.
///
/// **Which means the same confirmation, too.** It goes through `TrashConfirmations.requestDelete`
/// rather than straight to `BoardStore.deleteSelection`, because "the same chord deletes
/// permanently … confirmation per 03's recoverability rule" and a bare key that skipped the alert
/// the menu item raises would be the one path in the app where one keystroke destroys a card
/// silently. The staging itself is still the store's — the alert is the only thing this adds.
///
/// 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), ⇧⌘⌫
// (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 deleted 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.isEmpty else { return .ignored }
confirmations.requestDelete(in: store)
return .handled
}
/// **Escape steps outward one layer per press** (04 ▸ Grammar): abandon an open editor, else
/// clear the search, else clear the selection.
///
/// **The search takes Escape before its clear-selection meaning** (04 § Search, settled): "with
/// *board* focus and an active search, one press clears the search and the full board returns —
/// search takes Escape before its clear-selection meaning, which applies only when no search is
/// active." So a board-focused Escape under a query returns the board and *keeps* the selection;
/// a second press then deselects. One press, one layer, all the way out.
///
/// This is the third step of a staircase whose first two are the field's own — a non-empty field
/// clears its query and keeps the keyboard, an empty one hands the keyboard back here — and the
/// two halves never both fire, because exactly one of the field and the strip holds focus (see
/// `BoardSearchField`).
///
/// The editors handle Escape themselves while they hold focus; that 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
}
if !store.searchQuery.isEmpty {
store.clearSearch()
return .handled
}
guard !store.selection.isEmpty else { return .ignored }
store.clearSelection()
return .handled
}
// MARK: - The arrows
/// What modifier an arrow carried, reduced to the three meanings the grammar gives it — the
/// keyboard's `ClickModifier`.
private enum ArrowMode {
/// Plain: spatial navigation, replacing the selection.
case step
/// ⇧: extend the range from the anchor.
case extend
/// ⌥: jump to an end (04-interactions.md ▸ Grammar's "⌥-arrows jump").
case jump
}
/// **The arrow grammar's one door** (04-interactions.md ▸ Grammar; 11-command-nexus.md ▸ Fixed
/// grammar keys).
///
/// The handlers below are deliberately thin over pure functions — `NavigationMath` for the
/// geometry, `SelectionGrammar` for the order lists and the ranges — so what is written here is
/// dispatch and nothing else.
///
/// **⌘- and ⌥⌘-arrows never mean anything here.** They are menu key equivalents (Move Left/Right,
/// Move Up/Down, the lane width pair) and AppKit routes them to the menu before any view sees
/// them — but only while the item is *enabled*, so a disabled Move Right does deliver ⌘→ here.
/// Rejecting every combination but plain, ⇧ and ⌥ is what keeps a disabled command from silently
/// becoming a navigation gesture, and a mistyped text chord from moving the selection.
private func handleArrow(_ press: KeyPress) -> KeyPress.Result {
// "All grammar keys inert while a title editor is focused" — and the field owns the arrows
// as caret movement, so this guard is load-bearing rather than belt over braces.
guard !store.isEditingInline else { return .ignored }
guard let direction = Self.direction(of: press.key) else { return .ignored }
// Only the four meaningful flags are read: an arrow event also carries `.function` and
// `.numericPad` on macOS, and testing the whole set for emptiness would reject every press.
let modifiers = press.modifiers.intersection([.command, .control, .option, .shift])
let mode: ArrowMode
if modifiers.isEmpty {
mode = .step
} else if modifiers == .shift {
mode = .extend
} else if modifiers == .option {
mode = .jump
} else {
return .ignored
}
guard let origin = arrowOrigin() else { return seed(direction, mode) }
return origin.isLaneDomain
? laneArrow(direction, mode, from: origin.head)
: cardArrow(direction, mode, from: origin.head, in: origin.container)
}
private static func direction(of key: KeyEquivalent) -> NavigationMath.Direction? {
switch key.character {
case KeyEquivalent.upArrow.character: .up
case KeyEquivalent.downArrow.character: .down
case KeyEquivalent.leftArrow.character: .left
case KeyEquivalent.rightArrow.character: .right
default: nil
}
}
/// Where the next arrow steps from, and on which of the board's two levels — `nil` when the
/// selection names nothing to step from, which is the seed rule's cue.
///
/// The head is `TransientBoardState.selectionHead` when it is still in the order list, and
/// otherwise the selection's **last member in that list** — the same "last in flatten order"
/// anchor the ⌘N target rule and paste already share. That fallback is what makes a marquee, a
/// Select All and a foreign reload leave the arrows somewhere sensible without any of them
/// having to name a cursor.
///
/// The **trash's list is its rows**, top to bottom, both kinds interleaved by rank (lanes
/// rejoined 2026-07-29) — and the trash side is **never the lane domain**, whatever the
/// selection's kind: the lane domain is the strip's grammar (←/→ walk lanes, ↓ descends into
/// cards, ⌘←/⌘→ move one), and a trashed lane row is a row in a column, not a lane on the board
/// ("The trash lane itself is never selectable *as a lane*" — 04 ▸ The trash). Its arrows are the
/// spatial ones every row gets.
///
/// Both lists are the **filtered** board (04 § Search: "arrow nav … read[s] it"), so the
/// fallback lands on the last *visible* member rather than on a row the query hid.
private func arrowOrigin() -> (head: ItemID, container: ItemContainer, isLaneDomain: Bool)? {
let selection = store.selection
guard !selection.isEmpty else { return nil }
let isLaneDomain: Bool
let list: [ItemID]
switch selection.container {
case .board:
guard let kind = SelectionGrammar.kind(of: selection, in: store.snapshot) else { return nil }
isLaneDomain = kind == .lane
list = SelectionGrammar.order(of: kind, in: .board, snapshot: store.snapshot, filter: store.searchFilter)
case .trash:
isLaneDomain = false
list = trashRows
}
if let head = store.transient.selectionHead, list.contains(head) {
return (head, selection.container, isLaneDomain)
}
guard let last = list.last(where: { selection.ids.contains($0) }) else { return nil }
return (last, selection.container, isLaneDomain)
}
/// **An empty selection seeds at the first lane's first card** (04-interactions.md ▸ Grammar) —
/// a deterministic origin, so an arrow from nothing always means the same thing.
///
/// ⌥←/⌥→ are the exception, and the design states it: "the ⌥-jumps behave as specified
/// regardless". Those two name an *absolute* destination and need no origin, so they run
/// unchanged. ⌥↑/⌥↓ are relative to "the current lane", which an empty selection has none of, so
/// they seed like a plain arrow — which is exactly what makes "two ⌥↑ presses from nothing reach
/// the lane domain" true: the first seeds, the second escalates.
private func seed(_ direction: NavigationMath.Direction, _ mode: ArrowMode) -> KeyPress.Result {
if mode == .jump, direction == .left || direction == .right {
return jumpToEndLane(direction)
}
guard let first = Self.firstCard(scanning: boardLanes, filter: store.searchFilter) else { return .handled }
replaceSelection(with: first, in: .board)
return .handled
}
// MARK: Card domain
private func cardArrow(
_ direction: NavigationMath.Direction,
_ mode: ArrowMode,
from head: ItemID,
in container: ItemContainer
) -> KeyPress.Result {
switch mode {
case .step: step(direction, from: head)
case .extend: extend(direction, from: head)
case .jump:
switch direction {
case .left, .right: jumpToEndLane(direction)
case .up, .down: jumpWithinContainer(direction, from: head, in: container)
}
}
}
/// **Nearest card in the direction, across interior grid columns and lanes** — and across the
/// live/trash boundary too, since "plain arrows still walk across" (04 ▸ The trash).
///
/// Every registered target is a candidate, which is also how the hidden trash stays invisible:
/// a column that is not drawn registers nothing.
private func step(_ direction: NavigationMath.Direction, from head: ItemID) -> KeyPress.Result {
guard let origin = marqueeTargets.targets[head],
let nextID = NavigationMath.nearest(
from: origin.frame,
direction: direction,
among: marqueeTargets.all
),
let next = marqueeTargets.targets[nextID]
else { return .handled }
replaceSelection(with: next.id, in: next.container)
return .handled
}
/// **⇧-arrow extends, and stops at the container boundary** (04 ▸ The trash: "⇧-arrow extension
/// still stops at the container boundary") — a ⇧-arrow whose next step would cross from live
/// cards into the trash, or back, is simply inert.
///
/// **The kind boundary is no longer one of the stops inside the trash** (re-ruled 2026-07-31):
/// "⇧-click ranges, ⇧-arrow extension … all sweep every row" there, so a ⇧-arrow from a trash
/// card onto a lane row extends across it. On the board the kind test stands — the live grammar
/// is still cards XOR lanes.
///
/// The *step* that would cross is what goes inert — the crossing item is never stepped over in
/// search of a legal one, because that would silently drop the held range for a longer reach
/// than the user asked for. So the nearest neighbour is computed **unrestricted** and then
/// tested: a different side (or, on the board, a different kind) means this press does nothing
/// at all.
private func extend(_ direction: NavigationMath.Direction, from head: ItemID) -> KeyPress.Result {
guard let origin = marqueeTargets.targets[head],
let nextID = NavigationMath.nearest(
from: origin.frame,
direction: direction,
among: marqueeTargets.all
),
let next = marqueeTargets.targets[nextID],
next.container == origin.container,
next.container == .trash || next.kind == origin.kind
else { return .handled }
// An extension with no anchor makes one of where it started — the keyboard's equivalent of
// a ⇧-click after a marquee, which the grammar degrades to a plain click for the same
// reason: a range needs an origin, and the only honest one is the cursor's own position.
let anchor = store.transient.selectionAnchor ?? head
guard let ids = SelectionGrammar.range(
from: anchor,
to: next.id,
kind: next.kind,
in: next.container,
snapshot: store.snapshot,
// The span is the *filtered* board's, so a range under a search collects exactly the
// cards between the two endpoints that are on screen (04 § Search: "ranges … read it").
filter: store.searchFilter
) else { return .handled }
store.select(ids, in: next.container, anchor: anchor, head: next.id)
return .handled
}
/// **⌥↑/⌥↓ jump to the current container's first/last row** — the lane's cards, or the trash
/// column's rows when that is where the cursor is.
///
/// **⌥↑ escalates into the lane domain** (04 ▸ Grammar, settled — "the keyboard's one entry to
/// lane selection"): with the lane's first card already the sole selection, the next ⌥↑ selects
/// the *lane* itself. The trash deliberately never escalates: it "is never selectable as a lane",
/// so a second ⌥↑ there is simply inert.
///
/// **In the trash the jump crosses kinds**, like every other navigation there: the ends it names
/// are the column's, so ⌥↑ from a card can land on a lane row sitting above it.
private func jumpWithinContainer(
_ direction: NavigationMath.Direction,
from head: ItemID,
in container: ItemContainer
) -> KeyPress.Result {
let siblings: [ItemID]
var lane: ItemID?
switch container {
case .trash:
siblings = trashRows
case .board:
guard let home = store.snapshot.lanes.first(where: { lane in
lane.cards.contains { $0.id == head }
}) else { return .handled }
lane = home.id
// The container is what the lane is *showing*: a jump to "the lane's first card" under
// a search means its first surviving card, not one the filter animated out.
let filter = store.searchFilter
siblings = home.cards.filter { filter.matches($0) }.map(\.id)
}
guard let target = direction == .up ? siblings.first : siblings.last else { return .handled }
if direction == .up, target == head, let lane, store.selection.ids == [head] {
replaceSelection(with: lane, in: .board)
return .handled
}
replaceSelection(with: target, in: container)
return .handled
}
/// **⌥←/⌥→ to the first/last lane** (04 ▸ Grammar) — landing, in the card domain, on that lane's
/// first card, since ⌥↑ is the one keyboard entry to lane selection.
///
/// **⌥→ reaches the shown trash** first (04 ▸ The trash: "the shown trash is the last container
/// for card navigation, and ⌥→ jumps to it" — ▸ Grammar names the landing as "its first
/// **entry**", which is a row of either kind); an empty or hidden column is not a destination, so
/// the jump falls through to the last lane. Empty lanes are scanned past in both directions —
/// a jump that landed nowhere because the end lane happens to be empty would be a dead key.
private func jumpToEndLane(_ direction: NavigationMath.Direction) -> KeyPress.Result {
if direction == .right, isTrashVisible, let first = trashRows.first {
replaceSelection(with: first, in: .trash)
return .handled
}
let lanes = boardLanes
let filter = store.searchFilter
// A lane the search emptied is scanned past exactly as an empty one is — the jump lands on
// the first lane that is *showing* a card, which is what the user can see.
let target = direction == .right
? Self.firstCard(scanning: lanes.reversed(), filter: filter)
: Self.firstCard(scanning: lanes, filter: filter)
guard let target else { return .handled }
replaceSelection(with: target, in: .board)
return .handled
}
// MARK: Lane domain
/// The arrows with a **lane** selected (04-interactions.md ▸ Grammar, ▸ The map).
///
/// - ←/→ move the lane selection one lane, inert at the ends — **and the trash is never reached**
/// ("with a lane selected, ←/→ and ⌥→ stop at the last real lane"), which falls out for free
/// from walking the live lane order and nothing else.
/// - ⇧←/⇧→ extend that selection from the anchor, the same range a ⇧-click would give.
/// - ↓ descends back into the lane's cards at the first card, ⌥↓ at the last; an empty lane has
/// nothing to descend into.
/// - ↑ and ⌥↑ are inert: the lane domain is the top of the hierarchy.
/// - ⌥←/⌥→ jump to the first/last lane, staying in the lane domain.
private func laneArrow(
_ direction: NavigationMath.Direction,
_ mode: ArrowMode,
from head: ItemID
) -> KeyPress.Result {
let lanes = SelectionGrammar.lanes(in: store.snapshot)
guard let index = lanes.firstIndex(of: head) else { return .handled }
switch (direction, mode) {
case (.left, .step), (.right, .step), (.left, .extend), (.right, .extend):
let next = index + (direction == .left ? -1 : 1)
guard lanes.indices.contains(next) else { return .handled }
if mode == .step {
replaceSelection(with: lanes[next], in: .board)
} else {
let anchor = store.transient.selectionAnchor ?? head
guard let ids = SelectionGrammar.range(
from: anchor,
to: lanes[next],
kind: .lane,
in: .board,
snapshot: store.snapshot
) else { return .handled }
store.select(ids, in: .board, anchor: anchor, head: lanes[next])
}
case (.left, .jump), (.right, .jump):
guard let target = direction == .left ? lanes.first : lanes.last else { return .handled }
replaceSelection(with: target, in: .board)
case (.down, .step), (.down, .jump):
guard let lane = store.snapshot.lanes.first(where: { $0.id == head }) else {
return .handled
}
let cards = lane.cards
guard let target = mode == .jump ? cards.last : cards.first else { return .handled }
replaceSelection(with: target.id, in: .board)
case (.up, _), (.down, .extend):
// Nothing above the lane domain, and no vertical range within it.
break
}
return .handled
}
// MARK: Shared
/// A jump's and a plain step's shared landing: one item, both cursors on it.
private func replaceSelection(with id: ItemID, in container: ItemContainer) {
store.select([id], in: container, anchor: id, head: id)
}
/// The first rendered card of the first lane that has one — the scan every "first/last lane"
/// destination shares, run over the lane order forwards or reversed.
///
/// "Rendered" includes the search filter, so a lane whose cards the query all hid is scanned
/// past like an empty one — `liveCards(in:filter:)`'s membership, one lane at a time.
private static func firstCard(
scanning lanes: some Sequence<Lane>,
filter: SearchFilter = .inactive
) -> ItemID? {
for lane in lanes {
if let card = lane.cards.first(where: { filter.matches($0) }) { return card.id }
}
return nil
}
}