Files
lanework/Kanban/UI/Board/BoardView.swift
T
rzen 785ef5fe14 Realign code with the 2026-07-30 findings-resolution rulings
Delete Immediately is removed entirely (ruling ae1dd96, Redesign card
d40bfac1): the delete vocabulary is purely staged — board → trash,
trash → permanent (confirmed on no-git boards), Empty Trash for bulk.
Gone: File ▸ Delete Immediately (⌥⌘⌫) and its validation, both
⌥-alternate context rows (card + the permanently-disabled lane row),
the VO custom action, BoardStore.deleteImmediately,
TrashModel.canDeleteImmediately, TrashConfirmations' .purge action
(zero surviving callers — trash-side Delete always used
.deleteTrashCards), and the pinning tests. purgePrompt drops its
now-single-purpose container parameter (.trash is the only surviving
caller). BoardWriter.purgeItem survives — create-undo rollback still
needs it — with its comment rewritten. README's trash paragraph drops
the ⌥⌘⌫ sentence.

The other 2026-07-30 rulings (2ec2c95 registry freshness stamp,
c741b02 unified-log-as-coerce-consumer) required no code changes —
already conformant.

Both schemes 1844 tests / 318 suites green.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-30 15:13:31 -04:00

1058 lines
60 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: (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
/// 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: BoardMetrics.bodyPointSize) }
var body: some View {
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 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.rootURL, generation: generation)
}
.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: BoardMetrics.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 query changes rides it**, which is the reason the key is the query 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.
.animation(Motion.contentReflow(reduced: reduceMotion), value: store.searchQuery)
}
/// 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.
///
/// 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 or a
/// hand-written hex, 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` — every other surface on the board carries its
/// own opaque plate) 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.
///
/// 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.
///
/// A lane being **dragged** is simply absent from the strip: it is lifted out of the resting
/// layout at pickup and stays out until release, whatever the effective operation is
/// (DRAG-REORDER.md § Resting-layout zones), while the system drag session carries its replica.
@ViewBuilder
private func laneSlot(_ lane: Lane, standard: CGFloat) -> some View {
let resizing = resize.isResizing(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 {
DragShadow(
cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: BoardMetrics.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
)
.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: 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 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 cards as the column is showing them — the shown trash's cards "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 cards).
///
/// 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 card the
/// filter took away.
private var trashCards: [ItemID] {
SelectionGrammar.trashCards(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`).
///
/// 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.rootURL) != nil,
let source = session.sourceRoot,
!DragLocality.isSameBoard(source, store.rootURL)
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.rootURL)
}
/// 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, whatever the
/// effective operation is — with the shadows opened at the proposal.
private var stripSlots: [StripSlot] {
let session = appModel.dragSession
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
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 cards**, top to bottom — there are no lane entries to interleave
/// any more (03-board-ui.md § Trash: "Cards only").
///
/// 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 card 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 = trashCards
}
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 both boundaries** (04 ▸ The trash, settled): "a ⇧-arrow whose
/// next step would cross from live cards into the trash (or back), or from card entries onto a
/// lane entry within it, is simply inert".
///
/// 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 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.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 card** — the lane's, or the trash
/// trash column's 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.
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 = trashCards
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"); 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 = trashCards.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
}
}