Files
lanework/Kanban/UI/Board/BoardView.swift
T
rzen 90cf82d740 Implement drag & drop with the locality model
The second half: system drag sessions over phase 1's model, per
DRAG-REORDER.md and 04-interactions.md § Drag & drop.

- Card faces, lane headers, and trash rows drag as NSItemProvider sessions
  (two exported UTTypes, JSON payload in flatten order, plain-text titles as
  the secondary representation) — replacing m4's custom lane-reorder gesture
  and trash drag-out wholesale; the app-wide DragSession carries the members,
  the frozen dragged sizes, the live proposal, and the effective operation.
- Three drop delegates (lane masonry, strip, window fallback), each accepting
  both types and routing internally per the single-target-dispatch rule; the
  cursor is the physical mouse converted to strip space; proposals come from
  DropSlotMath with hysteresis threaded through, and the lane-strip proposal
  clamps in front of the shown trash.
- Locality picks the default — move within a board, copy across, the badge
  tracking live; ⌥ forces copy (ignored on within-board lane drags), ⌘
  forces move; trash rows restore within their board (positional), copy out
  across boards by default, ⌘ forcing the true restore-move.
- N contiguous shadows with reflow keyed on the proposal; the
  committed-overlay hold renders the dropped arrangement until the reload
  echo lands (1.5 s dissolution deadline for refused writes); the
  re-grounding trio: geometry re-derives per render, proposals re-validate
  by liveness at release, an emptied drag cancels itself.
- Edge autoscroll (ticking driver over DragAutoScrollMath, re-targeting per
  step), the mouse-up-gated late-event cleanup, and the polling watchdog —
  the pathfinder's lifecycle traps, ported.
- Store: moveLanes and multi-card restoreByDrag join the one-bracket drop
  commits.

784 unit tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-27 20:58:26 -04:00

890 lines
47 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, lanes and trash rows 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 tombstone, 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 trash quasi-lane** — trailing, one fixed unit, joining and leaving the width division as
/// View ▸ Show Trash toggles it (`TrashLaneView`, 03-board-ui.md § Trash).
///
/// ### What is deliberately not here yet
///
/// The toolbar and search belong to later milestone cards; so do external Finder file drops, which
/// 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 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 cards and trash rows 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
/// Whether the strip holds keyboard focus, which is what makes the grammar keys arrive. Restored
/// deliberately whenever an inline editor closes: the field that had focus is gone, and Return
/// must go back to meaning create/rename rather than nothing at all.
@FocusState private var isBoardFocused: Bool
/// The inter-lane gap, and the strip's outer margin — one number, because the standard-width
/// formula counts `units + 1` of them (03-board-ui.md § Layout; `LaneLayoutMath.standardWidth`).
private let spacing: CGFloat = 12
var body: some View {
GeometryReader { _ 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)
}
.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 *both* board types and routes
// internally, because single-target dispatch has no fall-through (DRAG-REORDER.md).
.onDrop(of: boardDragTypes, 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: boardDragTypes, 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 a focus target so the grammar keys reach it at all. The focus *ring* is off:
// the strip is the window's content, not a control, and a rectangle around the whole board
// would read as an error state.
.focusable()
.focusEffectDisabled()
.focused($isBoardFocused)
.onAppear { isBoardFocused = true }
.onChange(of: store.isEditingInline) { _, editing in
// An editor took focus and has now given it back. Without this the strip stays unfocused
// after every rename and Return silently stops working.
if !editing { isBoardFocused = true }
}
.onKeyPress(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() }
// **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(side: .live))
}
/// The lanes and the drag's shadows, plus the trash column when it is shown.
@ViewBuilder
private func laneStrip(_ slots: [StripSlot]) -> some View {
let standard = standardWidth
HStack(alignment: .top, spacing: spacing) {
ForEach(slots) { 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 a Put Back all reach the strip as a lane arriving in
// or leaving this `ForEach`; whether that *performs* is decided upstream, at
// the reload that carried it (`Motion.reloadAnimates`) — a transition with no
// animated transaction around it is simply an appearance.
.transition(Motion.laneTransition(reduced: reduceMotion))
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()
.frame(width: LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing))
.frame(maxHeight: .infinity)
}
}
if isTrashVisible {
// Trailing, always — the quasi-lane 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
// `liveLanes`) 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))
}
}
// 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 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)
.overlay(Rectangle().strokeBorder(Color.accentColor.opacity(0.5), lineWidth: 1))
.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. That runtime contrast computation (a hex background's
/// text colour, recomputed against the composited backdrop on appearance change) is not this
/// card's — what ships here is the palette path, whose twelve pairs are AA-verified at design
/// time.
///
/// A value that resolves to nothing paints nothing, so the window keeps the standard background:
/// the same lenient degrade as the other two levels, and the bytes stay as written.
@ViewBuilder
private var boardBackground: some View {
if let color = Palette.color(for: store.snapshot.background) {
color
}
}
// MARK: - Lanes
/// One lane's strip slot, plus its trailing grab strip.
///
/// Normally a plain `LaneView` sized to its unit count's slot width (a wide lane swallows the
/// interior gaps it spans). While THIS lane is being resized the slot holds two layers, kept
/// structurally stable so the `LaneView` never loses identity — its scroll position, its
/// masonry cache — across the drag:
///
/// • a shadow at the SNAPPED slot width, full strip height, behind the lane — the resting
/// footprint the siblings and the window are already aligned to;
/// • the live `LaneView` in front at `liveWidth`, which tracks the cursor and so overflows
/// (drawing over the right neighbour, hence the slot's `zIndex(1)`) or underfills the shadow
/// between ticks.
///
/// The outer frame is always the snapped slot width, so the `HStack` lays the other lanes out
/// off the tidy snapped layout regardless of the live overflow.
///
/// 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(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. **Tombstoned lanes render nowhere here** —
/// 03-board-ui.md § Trash collapses each into a single restorable entry in the trash quasi-lane
/// (a later card), and a lane that is not on the board consumes none of the window's width.
private var liveLanes: [Lane] {
store.snapshot.lanes.filter { !$0.isDeleted }
}
// MARK: - Trash
/// Whether the trash quasi-lane is on screen — transient, board-scoped, hidden on every open
/// (03-board-ui.md § Trash ▸ Visibility). Read in two places (the unit total and the slot), so it
/// gets a name rather than being spelled twice.
private var isTrashVisible: Bool {
store.transient.isTrashVisible
}
// 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 }
)
}
/// The strip's 1× lane width.
///
/// 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.
private var standardWidth: CGFloat {
if resize.isActive { return resize.standard }
var units = LaneLayoutMath.totalUnits(of: liveLanes, trashUnits: isTrashVisible ? 1 : 0)
units += arrivingLaneUnits
return LaneLayoutMath.standardWidth(
stripWidth: laneDrops.stripFrame.width,
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.
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 = liveLanes.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.liveness == .live,
selection.ids.count == 1,
let id = selection.ids.first,
let target = BoardStore.liveItem(id, in: store.snapshot)
else { return .ignored }
if target.cardID == nil {
store.transient.beginPlaceholder(inLane: target.laneID)
} else {
store.transient.beginRename(of: id, currentTitle: target.title)
}
return .handled
}
/// **Plain ⌫ tombstones the live selection** — "a plain-key synonym of File ▸ Delete, kept
/// grammar so no second 'Delete' title exists" (11-command-nexus.md; 04-interactions.md ▸ The
/// map).
///
/// Deliberately **live-only**: the nexus scopes this key to a live selection, and ⌫'s trash-side
/// role belongs to the ⌘⌫ twins, not to the bare key. A tombstoned selection is therefore inert
/// here — Put Back is a chord.
///
/// Inert while an inline editor is open, like every grammar key: the field owns ⌫ as backspace,
/// and a stray one reaching the board mid-edit would delete the item being renamed.
private func handleDelete(_ press: KeyPress) -> KeyPress.Result {
// **Plain ⌫, spelled out.** The modified chords belong to the menu — ⌘⌫ (Delete / Put Back),
// ⌥⌘⌫ (Delete Immediately), ⇧⌘⌫ (Empty Trash…) — and AppKit routes a key equivalent to the
// menu before the view sees it. But ⌥⌫ and ⌃⌫ are nobody's key equivalent, and a fall-through
// that tombstoned the selection on a mistyped text-editing chord would be exactly the kind of
// accident 04-interactions.md's fixed grammar is careful to avoid.
guard press.modifiers.intersection([.command, .option, .control, .shift]).isEmpty else {
return .ignored
}
guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
let selection = store.selection
guard selection.liveness == .live, !selection.isEmpty else { return .ignored }
store.deleteSelection()
return .handled
}
/// **Escape steps outward one layer per press** (04 ▸ Grammar): abandon an open editor, else
/// clear the selection.
///
/// The middle step — clearing an active search and returning focus to the board — is m5's, and
/// it slots between these two once the search field exists.
///
/// The editors handle Escape themselves while they hold focus; this branch is the outer net for
/// the case where focus has drifted off the field with an editor still open, and it abandons
/// both kinds because at most one can be open at a time.
private func handleEscape() -> KeyPress.Result {
if store.isEditingInline {
store.transient.discardPlaceholder()
store.transient.discardRename()
return .handled
}
guard !store.selection.isEmpty else { return .ignored }
store.clearSelection()
return .handled
}
// MARK: - 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, on: origin.side)
}
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 both kinds interleaved** (`TrashModel.entries`), because "arrows walk
/// every trash entry in its sorted order — card and lane entries alike" (04 ▸ The trash). The
/// per-kind lists are the *range*'s business, not the walk's.
private func arrowOrigin() -> (head: ItemID, side: Liveness, isLaneDomain: Bool)? {
let selection = store.selection
guard !selection.isEmpty else { return nil }
let isLaneDomain: Bool
let list: [ItemID]
switch selection.liveness {
case .live:
guard let kind = SelectionGrammar.kind(of: selection, in: store.snapshot) else { return nil }
isLaneDomain = kind == .lane
list = SelectionGrammar.order(of: kind, on: .live, in: store.snapshot)
case .trashed:
isLaneDomain = false
list = TrashModel.entries(of: store.snapshot).map(\.id)
}
if let head = store.transient.selectionHead, list.contains(head) {
return (head, selection.liveness, isLaneDomain)
}
guard let last = list.last(where: { selection.ids.contains($0) }) else { return nil }
return (last, selection.liveness, 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: liveLanes) else { return .handled }
replaceSelection(with: first, on: .live)
return .handled
}
// MARK: Card domain
private func cardArrow(
_ direction: NavigationMath.Direction,
_ mode: ArrowMode,
from head: ItemID,
on side: Liveness
) -> 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, on: side)
}
}
}
/// **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, on: next.side)
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.side == origin.side,
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,
on: next.side,
in: store.snapshot
) else { return .handled }
store.select(ids, liveness: next.side, anchor: anchor, head: next.id)
return .handled
}
/// **⌥↑/⌥↓ jump to the current container's first/last card** — the lane's, or the trash
/// quasi-lane'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,
on side: Liveness
) -> KeyPress.Result {
let container: [ItemID]
var lane: ItemID?
switch side {
case .trashed:
container = TrashModel.entries(of: store.snapshot).map(\.id)
case .live:
guard let home = store.snapshot.lanes.first(where: { lane in
!lane.isDeleted && lane.cards.contains { $0.id == head && !$0.isDeleted }
}) else { return .handled }
lane = home.id
container = home.cards.filter { !$0.isDeleted }.map(\.id)
}
guard let target = direction == .up ? container.first : container.last else { return .handled }
if direction == .up, target == head, let lane, store.selection.ids == [head] {
replaceSelection(with: lane, on: .live)
return .handled
}
replaceSelection(with: target, on: side)
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 = TrashModel.entries(of: store.snapshot).first {
replaceSelection(with: first.id, on: .trashed)
return .handled
}
let lanes = liveLanes
let target = direction == .right
? Self.firstCard(scanning: lanes.reversed())
: Self.firstCard(scanning: lanes)
guard let target else { return .handled }
replaceSelection(with: target, on: .live)
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.liveLanes(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], on: .live)
} else {
let anchor = store.transient.selectionAnchor ?? head
guard let ids = SelectionGrammar.range(
from: anchor,
to: lanes[next],
kind: .lane,
on: .live,
in: store.snapshot
) else { return .handled }
store.select(ids, liveness: .live, 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, on: .live)
case (.down, .step), (.down, .jump):
guard let lane = store.snapshot.lanes.first(where: { $0.id == head && !$0.isDeleted }) else {
return .handled
}
let cards = lane.cards.filter { !$0.isDeleted }
guard let target = mode == .jump ? cards.last : cards.first else { return .handled }
replaceSelection(with: target.id, on: .live)
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, on side: Liveness) {
store.select([id], liveness: side, 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.
private static func firstCard(scanning lanes: some Sequence<Lane>) -> ItemID? {
for lane in lanes {
if let card = lane.cards.first(where: { !$0.isDeleted }) { return card.id }
}
return nil
}
}