Modifiers were sampled only inside resolveOperation, reached from dropUpdated and the commit — and drop callbacks arrive only while the mouse moves, so ⌥ pressed against a still pointer changed nothing until the next twitch. DragSession now arms a local .flagsChanged watch for exactly the session's lifetime (begin arms, end stops, the watchdog guarantees end; ⇧/⌃/caps don't count, a settled hold swallows) and publishes modifierGeneration; the three retargets record which window's surface resolved the proposal (RetargetOrigin — weak registry identity, never board root, so cross-board and two-windows- one-board both answer correctly), and the hovered window replays that same retarget with the operation re-resolved FIRST, since the index space is a function of it. The monitor is injectable — the real one needs a live event stream no test bundle has. Drag-perf card d491e7d3. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
1132 lines
65 KiB
Swift
1132 lines
65 KiB
Swift
import AppKit
|
||
import SwiftUI
|
||
|
||
/// The lane strip — the board itself (03-board-ui.md § Layout — full visibility).
|
||
///
|
||
/// **Every lane is always on screen.** There is no horizontal scroll and no minimum lane width: the
|
||
/// window's width divides across the lanes' width units, a lane of n units taking n whole units of
|
||
/// that division, and resizing the window is the width control. A board with more units than the
|
||
/// window comfortably fits compresses every lane; that degenerate case is accepted, not floored
|
||
/// (the remedy is the user's — fewer units or a bigger window).
|
||
///
|
||
/// Two mechanisms change a lane's width and they are deliberately opposites (03-board-ui.md § Lane):
|
||
/// the **right-edge drag** grows or shrinks the *window* one standard width per snap so the other
|
||
/// lanes keep their exact pixels (`LaneResizeSession`), while the **stepper** — and its ⌥⌘→/⌥⌘←
|
||
/// keyboard face — re-divides the existing window width across the new unit total, compressing the
|
||
/// siblings and never touching the window (`BoardStore.setLaneWidth`).
|
||
///
|
||
/// ### The three interactions it hosts
|
||
///
|
||
/// - **Lane resize** — the right-edge grab strip (above). 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 {
|
||
// The strip's own body count (`BoardRenderMetrics`) — DEBUG only, and the discriminator
|
||
// between "the gate did not suppress" and "Observation invalidated the lane directly".
|
||
#if DEBUG
|
||
let _ = BoardRenderMetrics.countStripBody()
|
||
#endif
|
||
GeometryReader { proxy in
|
||
// The strip's slots: the lanes the strip should *show*, with the drag's N contiguous
|
||
// shadows opened at the proposal. Recomputed on every render, so a foreign reload
|
||
// mid-drag simply moves the zones (rule 1 of 04-interactions.md ▸ Drag and drop's
|
||
// re-grounding trio) — nothing about a drag is cached across a snapshot.
|
||
let slots = stripSlots
|
||
ZStack(alignment: .topLeading) {
|
||
backdrop
|
||
laneStrip(slots, standard: standardWidth(stripWidth: proxy.size.width))
|
||
}
|
||
.padding(spacing)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||
// The band, drawn **outside the padding** so its offset is a strip coordinate directly —
|
||
// and outside every animated modifier above, because 03-board-ui.md § Motion puts the
|
||
// marquee in the animation-free-by-construction list ("1:1 cursor following — animating
|
||
// input echo would be lag").
|
||
.overlay(alignment: .topLeading) { marqueeBand }
|
||
// The space the marquee is resolved in — see `BoardView.stripSpace`. It goes on the
|
||
// padded container so x = 0 is the strip's leading edge with the outer margin included,
|
||
// which is the origin `LaneLayoutMath`'s arithmetic assumes. Every marquee coordinate —
|
||
// the band's own drag samples and each registered item frame — is measured here too, so
|
||
// nothing ever converts between spaces.
|
||
.coordinateSpace(.named(Self.stripSpace))
|
||
// The same rectangle in the window's *global* space, which is where the physical cursor
|
||
// lands once converted (`BoardDropContext.globalCursor`). Written into the registry
|
||
// rather than into `@State` so the drop delegates read it live at event time rather than
|
||
// as of the last body evaluation.
|
||
.onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { laneDrops.stripFrame = $0 }
|
||
// **The strip's drop target** — the backdrop, the gaps, the outer margin, and the trash
|
||
// column's footprint, which is never a landing spot of its own (04-interactions.md ▸ The
|
||
// trash) and so simply falls through to here. It accepts *every* session type — ours and
|
||
// external Finder file drags alike — and routes internally, because single-target
|
||
// dispatch has no fall-through (DRAG-REORDER.md).
|
||
.onDrop(of: boardDropTypes, delegate: StripDropDelegate(context: dropContext))
|
||
}
|
||
.background(boardBackground)
|
||
// The window-level fallback, *behind* the specific targets: a release over any in-window
|
||
// region they don't cover commits the current proposal instead of leaking the session into a
|
||
// cancel-snapback — the drop lands where the shadows show, which is what the shadows promise.
|
||
.background {
|
||
Color.clear
|
||
.onDrop(of: boardDropTypes, delegate: BoardFallbackDropDelegate(context: dropContext))
|
||
}
|
||
// **The committed-overlay hold's hand-off** (DRAG-REORDER.md § The committed-overlay hold):
|
||
// the overlay stands in for an arrangement that is on disk but not yet in the snapshot, and
|
||
// discards itself the moment a snapshot lands — because holding a moment longer would draw
|
||
// the arrangement twice.
|
||
.onChange(of: store.snapshotGeneration) { _, generation in
|
||
appModel.dragSession.handOff(root: store.rootKey, generation: generation)
|
||
}
|
||
// **The lane-resize release hold's hand-off** (`LaneWidthHold`), the same shape one rung
|
||
// narrower: that overlay stands in for an *arrangement* and any landing retires it, this one
|
||
// for a *width* and only a landing that carries it will do — an unrelated reload arriving
|
||
// first must not hand the strip back to a snapshot that still says the old unit count.
|
||
//
|
||
// **`landedReloads`, not `snapshotGeneration`**: the question is "is the width current",
|
||
// asked on every walk that landed, so a reload whose tree came back value-equal — which
|
||
// skips the assignment and moves no generation — still gets to answer it.
|
||
.onChange(of: store.landedReloads) { _, _ in
|
||
resize.handOff(against: store.snapshot.lanes)
|
||
}
|
||
// **The stationary modifier flip's re-proposal** (`BoardDropContext.retargetAfterModifierFlip`).
|
||
// Drop callbacks arrive only while the mouse moves, so a ⌥ pressed against a still pointer
|
||
// reaches the drag through the session's `.flagsChanged` watch instead — and lands back on
|
||
// the one shared retarget here, in the board window whose surface resolved the proposal.
|
||
//
|
||
// Every open board window carries this handler and exactly one of them acts: the flip
|
||
// replays against a `LaneDropRegistry`, which is this window's alone.
|
||
//
|
||
// **No transaction of its own**, deliberately: a flip must produce exactly what the next
|
||
// mouse sample would have produced, and `dropUpdated` wraps nothing either. What moves is
|
||
// keyed declaratively where it is drawn — the strip on its proposal, each lane on its shadow
|
||
// run (03-board-ui.md § Motion, transactions keyed narrowly).
|
||
.onChange(of: appModel.dragSession.modifierGeneration) { _, _ in
|
||
dropContext.retargetAfterModifierFlip()
|
||
}
|
||
.trashPurgeAlert(store: store, confirmations: confirmations)
|
||
// The board's own anchor for the Style… popover — the surface a board-targeted session hangs
|
||
// off, since the board has no item to attach to (`styleEditorPresentation`'s `nil` anchor).
|
||
.popover(isPresented: styleEditorPresentation(store, anchor: nil), arrowEdge: .top) {
|
||
StyleEditorPopover(store: store, recents: appModel.styleRecents)
|
||
}
|
||
// **The board is one tab stop** (10-accessibility.md ▸ Full Keyboard Access: "the board is
|
||
// one tab stop with arrow-key navigation within"), and this is it: one focusable strip,
|
||
// whose interior movement is the arrow grammar below rather than a focus stop per card.
|
||
// The controls *around* the cards — each lane's new-card button, the popovers, the toolbar —
|
||
// are ordinary `Button`s and stay Tab-reachable in their own right, which is the other half
|
||
// of the same sentence ("every control … is Tab-reachable").
|
||
//
|
||
// The focus *ring* is normally off: the strip is the window's content, not a control, and a
|
||
// rectangle around the whole board would read as an error state. **Under Full Keyboard
|
||
// Access it comes back**, because that reasoning inverts completely there — FKA's premise is
|
||
// that the user can see where Tab landed, and an invisible tab stop is not one
|
||
// (`Accommodations.isFullKeyboardAccessEnabled`).
|
||
.focusable()
|
||
.focusEffectDisabled(!Accommodations.isFullKeyboardAccessEnabled)
|
||
.focused($isBoardFocused)
|
||
.onAppear {
|
||
isBoardFocused = true
|
||
// **Escape's second step, wired from the side that can perform it** (04 § Search): the
|
||
// field can resign first responder on its own, but only the strip can *take* the
|
||
// keyboard, and a window with a resigned field and an unfocused board would swallow
|
||
// every grammar key. `@FocusState`'s setter is nonmutating, so the closure writes the
|
||
// same storage this view reads.
|
||
search.focusBoard = { isBoardFocused = true }
|
||
}
|
||
.onChange(of: store.isEditingInline) { _, editing in
|
||
// An editor took focus and has now given it back. Without this the strip stays unfocused
|
||
// after every rename and Return silently stops working.
|
||
if !editing { isBoardFocused = true }
|
||
}
|
||
.onKeyPress(keys: [.return], phases: .down) { handleReturn($0) }
|
||
.onKeyPress(.escape) { handleEscape() }
|
||
.onKeyPress(keys: [.delete], phases: .down) { handleDelete($0) }
|
||
// **The arrows** (04-interactions.md ▸ Grammar), on `.down` *and* `.repeat`: holding an
|
||
// arrow must walk the board, and a handler registered for `.down` alone sees the first
|
||
// press only.
|
||
.onKeyPress(
|
||
keys: [.upArrow, .downArrow, .leftArrow, .rightArrow],
|
||
phases: [.down, .repeat]
|
||
) { handleArrow($0) }
|
||
// **Select All** (04-interactions.md ▸ The map). Edit ▸ Select All is the standard menu
|
||
// item and it dispatches `selectAll:` down the responder chain, so the board answers it as a
|
||
// responder rather than growing a second menu item with the same title — which titles-are-API
|
||
// forbids outright (04 ▸ Configurable bindings). A focused text field consumes it first, so
|
||
// ⌘A inside an inline editor stays text selection with no guard needed here.
|
||
.onCommand(#selector(NSText.selectAll(_:))) { store.selectAll() }
|
||
// **Cut / Copy / Paste** (04-interactions.md ▸ Clipboard), through the same responder door
|
||
// Select All above uses and for the same titles-are-API reason. Each handler is attached only
|
||
// while its command applies, which is what makes AppKit's automatic enablement mirror the
|
||
// validation exactly — see `boardClipboardCommands`.
|
||
.boardClipboardCommands(store: store, clipboard: appModel.clipboard)
|
||
// **Belt** for a session SwiftUI does report ending: immediate cleanup, gated on the physical
|
||
// mouse button being up. A finished session's phase events can arrive *after* the user has
|
||
// started the next drag, and an ungated handler would wipe the new session's state — no
|
||
// shadow, drop dead. `DragSession`'s watchdog is the braces (see `armWatchdog`), and it is
|
||
// what makes refusing here free.
|
||
.onDragSessionUpdated { session in
|
||
switch session.phase {
|
||
case .ended, .dataTransferCompleted:
|
||
MainActor.assumeIsolated { appModel.dragSession.endIfButtonReleased() }
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - The strip's layers
|
||
|
||
/// The empty surface behind the lanes: a plain click clears the selection, a drag rubber-bands.
|
||
///
|
||
/// `Color.clear` with a `contentShape` rather than a real fill — the board's *painted*
|
||
/// background is `boardBackground`, outside the geometry reader, and this layer exists only to
|
||
/// be hit. Modified clicks are deliberately no-ops: ⌘ and ⇧ on the backdrop name no target, and
|
||
/// Finder's own desktop behaves the same way.
|
||
private var backdrop: some View {
|
||
Color.clear
|
||
.contentShape(Rectangle())
|
||
.onTapGesture {
|
||
guard ClickModifier.current == .plain else { return }
|
||
store.clearSelection()
|
||
}
|
||
.simultaneousGesture(marqueeControl.gesture(in: .board))
|
||
}
|
||
|
||
/// The lanes and the drag's shadows, plus the trash column when it is shown.
|
||
@ViewBuilder
|
||
private func laneStrip(_ slots: [StripSlot], standard: CGFloat) -> some View {
|
||
HStack(alignment: .top, spacing: spacing) {
|
||
ForEach(Array(slots.enumerated()), id: \.element.id) { index, slot in
|
||
switch slot {
|
||
case let .lane(lane):
|
||
laneSlot(lane, standard: standard)
|
||
// "Appear/disappear is scale + fade … lanes ~0.9" (03-board-ui.md § Motion).
|
||
// A create, a delete and an undo all reach the strip as a lane arriving in
|
||
// or leaving this `ForEach`; whether that *performs* is decided upstream, at
|
||
// the reload that carried it (`Motion.reloadAnimates`) — a transition with no
|
||
// animated transaction around it is simply an appearance.
|
||
.transition(Motion.laneTransition(reduced: reduceMotion))
|
||
// **Lanes are read in lane `order`** (10-accessibility.md ▸ The board
|
||
// through VoiceOver). Geometry already agrees — an `HStack` lays the slots
|
||
// out left to right in this very sequence — so unlike the masonry's
|
||
// column-major divergence (`LaneView`) this is a statement rather than a
|
||
// correction. It is written anyway for what it buys below: the priorities
|
||
// stay above the trash's, which is the only way "the trash is the LAST
|
||
// container" survives a right-to-left layout direction or a lane slot the
|
||
// resize session lifts to `zIndex(1)`.
|
||
.accessibilitySortPriority(Double(slots.count - index))
|
||
case let .shadow(_, units):
|
||
// One of the drag's N contiguous shadows, at the exact width the arriving lane
|
||
// will occupy — its units measured against *this* strip's standard, which is
|
||
// what makes the drop land precisely where the shadow shows.
|
||
DragShadow(cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: 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 filter changes rides it**, which is the reason the key is the filter rather
|
||
// than the transaction being wrapped at each mutation: typing, the field's Escape, the
|
||
// board's Escape, and creation's clear (`TransientBoardState.beginPlaceholder`) all land
|
||
// here without any of them knowing about motion.
|
||
//
|
||
// The *filter*, not the query, since comments joined the search's scope (04 ▸ Search, re-ruled
|
||
// 2026-07-29): a landed comment-index sweep widens what matches without the query changing at
|
||
// all, and keying on the query alone would make those cards pop in while every other arrival
|
||
// eased. `SearchFilter` is `Equatable` and carries the index' answer, so one key covers both.
|
||
.animation(Motion.contentReflow(reduced: reduceMotion), value: store.searchFilter)
|
||
}
|
||
|
||
/// The rubber band itself: a translucent accent fill with a hairline border, in strip
|
||
/// coordinates and **never animated** (03-board-ui.md § Motion — the marquee "tracks the cursor
|
||
/// 1:1", and an eased band visibly lags the mouse).
|
||
///
|
||
/// Hit-testing off, because the band is feedback: the drag that draws it is already recognised,
|
||
/// and a rectangle that swallowed clicks would eat the release.
|
||
@ViewBuilder
|
||
private var marqueeBand: some View {
|
||
if let rect = marquee.rect {
|
||
Rectangle()
|
||
.fill(Color.accentColor.opacity(0.12))
|
||
.frame(width: rect.width, height: rect.height)
|
||
// Increase Contrast takes the band's edge to full strength and a point heavier: the
|
||
// fill is a 12%-alpha wash by design, so the border is the only thing that says where
|
||
// the sweep actually reaches (`Accommodations`).
|
||
.overlay(
|
||
Rectangle().strokeBorder(
|
||
Color.accentColor.opacity(Accommodations.accentOpacity(0.5, contrast: contrast)),
|
||
lineWidth: Accommodations.borderWidth(1, contrast: contrast)
|
||
)
|
||
)
|
||
.offset(x: rect.minX, y: rect.minY)
|
||
.allowsHitTesting(false)
|
||
}
|
||
}
|
||
|
||
/// What the strip lends its empty surfaces and its sweepable items — the band's session, the
|
||
/// registry, and the store it selects into (`MarqueeControl`).
|
||
private var marqueeControl: MarqueeControl {
|
||
MarqueeControl(session: marquee, registry: marqueeTargets, store: store)
|
||
}
|
||
|
||
// MARK: - Styling
|
||
|
||
/// The board's `background`, painting "the board window's content background (the surface behind
|
||
/// and between lanes)" (03-board-ui.md § Styling ▸ Capabilities).
|
||
///
|
||
/// Unlike the lane band and the card stripe this one is a **fill**, because at board level that
|
||
/// is what the design asks for — and it is why the board is the level 10-accessibility.md binds
|
||
/// its ≥ 4.5:1 rule to: text does sit on it.
|
||
///
|
||
/// 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 as a move** is simply absent from the strip: it is lifted out of the
|
||
/// resting layout at pickup and stays out until release, while the system drag session carries
|
||
/// its replica. A lane being dragged as a **copy** stays in the strip instead, because the copy
|
||
/// leaves it there (DRAG-REORDER.md § Resting-layout zones) — which on this board means the
|
||
/// cross-board case alone, ⌥ being ignored by a within-board lane drag.
|
||
@ViewBuilder
|
||
private func laneSlot(_ lane: Lane, standard: CGFloat) -> some View {
|
||
// **The session governs through the release**, not just the drag: after the release its
|
||
// frozen standard and the unit count it *wrote* keep answering here until the snapshot
|
||
// carries that width back (`LaneWidthHold`). Reading `lane.width` in that window would draw
|
||
// the pre-drag layout for a round trip.
|
||
let resizing = resize.governs(lane.id)
|
||
let units = resize.displayUnits(of: lane)
|
||
let slotWidth = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing)
|
||
ZStack(alignment: .topLeading) {
|
||
if resizing {
|
||
DragShadow(
|
||
cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: 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
|
||
)
|
||
// **The value gate** (`LaneView.==`): this body re-runs on every drop-proposal change,
|
||
// and without this every lane on the board — and every card face in it — rebuilds on
|
||
// every cursor move of every drag.
|
||
.equatable()
|
||
.frame(width: resizing ? resize.liveWidth : slotWidth, alignment: .leading)
|
||
}
|
||
.frame(width: slotWidth, alignment: .topLeading)
|
||
.zIndex(resizing ? 1 : 0)
|
||
.overlay(alignment: .trailing) {
|
||
LaneResizeHandle(
|
||
store: store,
|
||
session: resize,
|
||
laneID: lane.id,
|
||
committedUnits: units,
|
||
standard: standard,
|
||
gap: spacing,
|
||
window: window
|
||
)
|
||
// The read-only lock disables every mutating gesture, not just the menu items
|
||
// (02-architecture.md § The lock's scope). It matters more here than elsewhere: a drag
|
||
// resizes the *window* on the way, so a refused commit would leave the window grown
|
||
// around a lane that snapped back — and the lock's row is already saying why nothing
|
||
// can be written. The focused-editor rule closes it too, like every board command, and
|
||
// so does a drag session in flight — the edge drag "refuses to start while a card/lane
|
||
// session is in flight" (DRAG-REORDER.md § Adjacent interaction): two gestures mutating
|
||
// one strip layout is not a state this view has a meaning for.
|
||
.disabled(store.isReadOnly || store.isEditingInline || appModel.dragSession.isActive)
|
||
}
|
||
}
|
||
|
||
/// The lanes the strip lays out, in snapshot order — every lane the board has. Deleting a lane
|
||
/// is physical now (03-board-ui.md § Trash: "Cards only. Lanes are never trashed"), so a lane in
|
||
/// the snapshot is a lane on the board, with no hidden state to filter for.
|
||
private var boardLanes: [Lane] {
|
||
store.snapshot.lanes
|
||
}
|
||
|
||
// MARK: - Trash
|
||
|
||
/// Whether the trash column is on screen — transient, board-scoped, hidden on every open
|
||
/// (03-board-ui.md § Trash ▸ Visibility). Read in two places (the unit total and the slot), so it
|
||
/// gets a name rather than being spelled twice.
|
||
private var isTrashVisible: Bool {
|
||
store.transient.isTrashVisible
|
||
}
|
||
|
||
/// The trash's **rows** as the column is showing them, both kinds in rank order — the shown
|
||
/// trash's contents "participate in the filter exactly like any other card" (03-board-ui.md §
|
||
/// Trash), and the arrows walk what is on screen (`TrashLaneView` applies the identical predicate
|
||
/// to the identical rows).
|
||
///
|
||
/// **Rows and not cards, because this is navigation** (04-interactions.md ▸ The trash: "inside,
|
||
/// plain arrows walk every row, card and lane row alike (navigation crosses kinds)", and ▸
|
||
/// Grammar gives ⌥→ "the shown non-empty trash — its first *entry*"). The kind-scoped lists are
|
||
/// the *ranging* grammar's (`SelectionGrammar.order`), which is what stops a ⇧-arrow at the kind
|
||
/// boundary while a plain one crosses it.
|
||
///
|
||
/// Read by the three keyboard destinations that reach into the column — the arrow origin's
|
||
/// order list, ⌥↑/⌥↓'s container, and ⌥→'s jump — so none of them can walk onto a row the
|
||
/// filter took away.
|
||
private var trashRows: [ItemID] {
|
||
SelectionGrammar.trashRows(in: store.snapshot, filter: store.searchFilter)
|
||
}
|
||
|
||
// MARK: - The drag
|
||
|
||
/// What each of this window's drop targets — and its lanes' autoscroll drivers — is handed.
|
||
///
|
||
/// Every geometric input is a **closure**, read at event time (`BoardDropContext`): a captured
|
||
/// snapshot of the strip's frame or its standard width goes stale the moment the layout animates,
|
||
/// and two delegates holding different snapshots would flap the proposal between them.
|
||
private var dropContext: BoardDropContext {
|
||
BoardDropContext(
|
||
store: store,
|
||
session: appModel.dragSession,
|
||
registry: laneDrops,
|
||
gap: spacing,
|
||
window: window,
|
||
stripFrame: { laneDrops.stripFrame },
|
||
standard: { standardWidth(stripWidth: laneDrops.stripFrame.width) }
|
||
)
|
||
}
|
||
|
||
/// The strip's 1× lane width, for a strip `stripWidth` points wide.
|
||
///
|
||
/// **The width is a parameter, and which width the caller passes matters.** Body evaluation
|
||
/// passes the `GeometryReader`'s live proposal, because that is the one source that invalidates
|
||
/// the view when it changes — the registry's `stripFrame` is deliberately unobserved (it exists
|
||
/// for event-time reads), so a body computed from it renders the *previous* layout's width: on
|
||
/// first open that is `.zero`, every lane collapses to `standardWidth`'s 1pt floor, and the
|
||
/// board stays that way until an unrelated invalidation happens by. The drop delegates pass
|
||
/// `laneDrops.stripFrame.width` instead, which is the same number read at event time — exactly
|
||
/// the registry's purpose. The two agree because the padded container fills the reader.
|
||
///
|
||
/// During a resize session the standard is **frozen** at its drag-start value: the window is
|
||
/// animating mid-resize, so deriving the standard from the live width would feed that animation
|
||
/// back into every lane and pulse the whole strip. The window is sized on each tick so this frozen
|
||
/// value equals what the formula yields once the session ends — the handoff is seamless (see
|
||
/// `LaneResizeSession`).
|
||
///
|
||
/// **"Once the session ends" is the echo, not the release** (`LaneWidthHold`). The equality that
|
||
/// makes the handoff seamless is between the frozen standard and the formula run over the *new*
|
||
/// unit total, and the write takes a watcher round trip to put that total in the snapshot;
|
||
/// dividing the already-grown window by the stale total in between is exactly the two-step the
|
||
/// hold exists to remove.
|
||
///
|
||
/// Otherwise it is the ordinary division, over three contributions:
|
||
///
|
||
/// - the **live lanes**' units. A lane in flight is still a lane on the board, so a within-board
|
||
/// drag does *not* re-divide the strip: "the shadow occupies its units" (DRAG-REORDER.md § The
|
||
/// lane strip's resting layout is arithmetic). Recomputing at pickup and again at release is
|
||
/// exactly the motion-feeds-back-into-logic failure the model exists to avoid.
|
||
/// - the **trash's** one fixed unit, *only while shown* — the whole of "Show/Hide Trash is a
|
||
/// re-divide trigger" (03-board-ui.md § Trash): the window is never touched, the existing width
|
||
/// simply divides across one more unit and every lane compresses.
|
||
/// - a **cross-board lane arrival**'s units while its shadow hovers here, by the same rule read
|
||
/// from the destination's side: the shadow occupies its units, and the strip has to make room
|
||
/// for them or the shadow would be drawn at a width the lane will not have
|
||
/// (`arrivingLaneUnits`).
|
||
private func standardWidth(stripWidth: CGFloat) -> CGFloat {
|
||
if resize.isActive { return resize.standard }
|
||
var units = LaneLayoutMath.totalUnits(of: boardLanes, trashUnits: isTrashVisible ? 1 : 0)
|
||
units += arrivingLaneUnits
|
||
return LaneLayoutMath.standardWidth(
|
||
stripWidth: stripWidth,
|
||
totalUnits: units,
|
||
gap: spacing
|
||
)
|
||
}
|
||
|
||
/// The units a cross-board lane run would add to this strip while its shadow is proposed here;
|
||
/// zero for a within-board drag, whose lanes are already counted.
|
||
///
|
||
/// **A cross-board lane arrival pre-divides the destination strip during hover**
|
||
/// (04-interactions.md ▸ Drag and drop, settled): "while a foreign lane drag proposes into a
|
||
/// board, the destination's standard width is computed with the arriving run's units included, so
|
||
/// the shadow draws at the width the lane will actually take — without this it overflows the
|
||
/// strip (the pathfinder's `stripWidthUnits`)". The units are the run's own, frozen at pickup by
|
||
/// the *source* board (`DragSession.laneUnits`) — both boards are open in this app, so nothing
|
||
/// has to cross the pasteboard for the destination to know how wide its visitor is.
|
||
///
|
||
/// Gated on a live proposal *on this board* rather than on hover alone, which is the same bullet's
|
||
/// accepted residue: "the first entry samples the un-widened standard for one frame before
|
||
/// hysteresis settles — accepted, imperceptible".
|
||
///
|
||
/// One number, two readers: body evaluation draws the strip and the shadow with it
|
||
/// (`standardWidth`), and the drop delegates retarget against it through `dropContext.standard`,
|
||
/// so the zones can never disagree with what is on screen.
|
||
private var arrivingLaneUnits: Int {
|
||
let session = appModel.dragSession
|
||
guard session.isDraggingLanes,
|
||
session.stripProposal(onBoardRooted: store.rootKey) != nil,
|
||
let source = session.sourceRoot,
|
||
source != store.rootKey
|
||
else { return 0 }
|
||
return session.laneUnits.reduce(0, +)
|
||
}
|
||
|
||
/// The strip's current lane-drop proposal — the reflow's narrow animation key, and where the
|
||
/// shadow run opens.
|
||
private var stripProposal: Int? {
|
||
appModel.dragSession.stripProposal(onBoardRooted: store.rootKey)
|
||
}
|
||
|
||
/// One position in the strip: a lane, or one of the drag's N contiguous shadows.
|
||
private enum StripSlot: Identifiable {
|
||
case lane(Lane)
|
||
case shadow(index: Int, units: Int)
|
||
|
||
var id: String {
|
||
switch self {
|
||
case let .lane(lane): "lane:\(lane.id.rawValue)"
|
||
// Constant per position, so a shadow run keeps its identity as the proposal slides and
|
||
// the run animates as a move rather than blinking out and back in.
|
||
case let .shadow(index, _): "shadow:\(index)"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// What the strip lays out: the resting lanes — the dragged run lifted out when the release
|
||
/// would be a move, left standing when it would be a copy (`DragSession.hiddenMembers`) — with
|
||
/// the shadows opened at the proposal.
|
||
private var stripSlots: [StripSlot] {
|
||
let session = appModel.dragSession
|
||
let hidden = session.hiddenMembers(onBoardRooted: store.rootKey)
|
||
var slots = boardLanes.filter { !hidden.contains($0.id) }.map(StripSlot.lane)
|
||
guard let index = stripProposal else { return slots }
|
||
let shadows = session.laneUnits.enumerated().map { StripSlot.shadow(index: $0.offset, units: $0.element) }
|
||
slots.insert(contentsOf: shadows, at: min(max(0, index), slots.count))
|
||
return slots
|
||
}
|
||
|
||
// MARK: - Grammar keys
|
||
|
||
/// **Return**, narrowly (04-interactions.md ▸ Grammar): a sole selected live card begins an
|
||
/// inline rename, a sole selected live lane begins a new-card placeholder at its bottom, and
|
||
/// everything else is ignored — a multi-card selection is explicitly inert, and a lane's rename
|
||
/// path is Board ▸ Rename precisely because Return on a lane creates.
|
||
///
|
||
/// Inert while an inline editor is open: "all grammar keys inert while a title editor is
|
||
/// focused". The field consumes Return itself, so this guard is belt over braces — but the belt
|
||
/// matters, because a stray Return reaching here mid-edit would open a *second* editor.
|
||
private func handleReturn(_ press: KeyPress) -> KeyPress.Result {
|
||
// **Plain Return only**, the delete handler's rule for its reason. ⌘↩ belongs to Board ▸
|
||
// Open Card and AppKit routes it to the menu first — but only while that item is *enabled*,
|
||
// and a disabled one lets the chord fall through to here. ⌥↩ and ⇧↩ are nobody's key
|
||
// equivalent at all. Neither may open a rename or a placeholder.
|
||
guard press.modifiers.intersection([.command, .option, .control, .shift]).isEmpty else {
|
||
return .ignored
|
||
}
|
||
guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
|
||
let selection = store.selection
|
||
guard selection.container == .board,
|
||
selection.ids.count == 1,
|
||
let id = selection.ids.first,
|
||
let target = BoardStore.boardItem(id, in: store.snapshot)
|
||
else { return .ignored }
|
||
|
||
if target.cardID == nil {
|
||
store.transient.beginPlaceholder(inLane: target.laneID)
|
||
} else {
|
||
store.transient.beginRename(of: id, currentTitle: target.title)
|
||
}
|
||
return .handled
|
||
}
|
||
|
||
/// **Plain ⌫ deletes the selection** — "a plain-key synonym of File ▸ Delete, kept grammar so no
|
||
/// second 'Delete' title exists" (11-command-nexus.md; 04-interactions.md ▸ The map).
|
||
///
|
||
/// **Both stagings**, unlike the tombstone era's live-only reading: "Plain ⌫ performs the same
|
||
/// delete as fixed grammar" (04-interactions.md ▸ The map, resettled 2026-07-28) — a board
|
||
/// selection moves into `.trash/`, a trash selection deletes permanently.
|
||
///
|
||
/// **Which means the same confirmation, too.** It goes through `TrashConfirmations.requestDelete`
|
||
/// rather than straight to `BoardStore.deleteSelection`, because "the same chord deletes
|
||
/// permanently … confirmation per 03's recoverability rule" and a bare key that skipped the alert
|
||
/// the menu item raises would be the one path in the app where one keystroke destroys a card
|
||
/// silently. The staging itself is still the store's — the alert is the only thing this adds.
|
||
///
|
||
/// Inert while an inline editor is open, like every grammar key: the field owns ⌫ as backspace,
|
||
/// and a stray one reaching the board mid-edit would delete the item being renamed.
|
||
private func handleDelete(_ press: KeyPress) -> KeyPress.Result {
|
||
// **Plain ⌫, spelled out.** The modified chords belong to the menu — ⌘⌫ (Delete), ⇧⌘⌫
|
||
// (Empty Trash…) — and AppKit routes a key equivalent to the menu before the view sees it.
|
||
// But ⌥⌫ and ⌃⌫ are nobody's key equivalent, and a fall-through that deleted the selection on
|
||
// a mistyped text-editing chord would be exactly the kind of accident 04-interactions.md's
|
||
// fixed grammar is careful to avoid.
|
||
guard press.modifiers.intersection([.command, .option, .control, .shift]).isEmpty else {
|
||
return .ignored
|
||
}
|
||
guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
|
||
let selection = store.selection
|
||
guard !selection.isEmpty else { return .ignored }
|
||
confirmations.requestDelete(in: store)
|
||
return .handled
|
||
}
|
||
|
||
/// **Escape steps outward one layer per press** (04 ▸ Grammar): abandon an open editor, else
|
||
/// clear the search, else clear the selection.
|
||
///
|
||
/// **The search takes Escape before its clear-selection meaning** (04 § Search, settled): "with
|
||
/// *board* focus and an active search, one press clears the search and the full board returns —
|
||
/// search takes Escape before its clear-selection meaning, which applies only when no search is
|
||
/// active." So a board-focused Escape under a query returns the board and *keeps* the selection;
|
||
/// a second press then deselects. One press, one layer, all the way out.
|
||
///
|
||
/// This is the third step of a staircase whose first two are the field's own — a non-empty field
|
||
/// clears its query and keeps the keyboard, an empty one hands the keyboard back here — and the
|
||
/// two halves never both fire, because exactly one of the field and the strip holds focus (see
|
||
/// `BoardSearchField`).
|
||
///
|
||
/// The editors handle Escape themselves while they hold focus; that branch is the outer net for
|
||
/// the case where focus has drifted off the field with an editor still open, and it abandons
|
||
/// both kinds because at most one can be open at a time.
|
||
private func handleEscape() -> KeyPress.Result {
|
||
if store.isEditingInline {
|
||
store.transient.discardPlaceholder()
|
||
store.transient.discardRename()
|
||
return .handled
|
||
}
|
||
if !store.searchQuery.isEmpty {
|
||
store.clearSearch()
|
||
return .handled
|
||
}
|
||
guard !store.selection.isEmpty else { return .ignored }
|
||
store.clearSelection()
|
||
return .handled
|
||
}
|
||
|
||
// MARK: - The arrows
|
||
|
||
/// What modifier an arrow carried, reduced to the three meanings the grammar gives it — the
|
||
/// keyboard's `ClickModifier`.
|
||
private enum ArrowMode {
|
||
/// Plain: spatial navigation, replacing the selection.
|
||
case step
|
||
/// ⇧: extend the range from the anchor.
|
||
case extend
|
||
/// ⌥: jump to an end (04-interactions.md ▸ Grammar's "⌥-arrows jump").
|
||
case jump
|
||
}
|
||
|
||
/// **The arrow grammar's one door** (04-interactions.md ▸ Grammar; 11-command-nexus.md ▸ Fixed
|
||
/// grammar keys).
|
||
///
|
||
/// The handlers below are deliberately thin over pure functions — `NavigationMath` for the
|
||
/// geometry, `SelectionGrammar` for the order lists and the ranges — so what is written here is
|
||
/// dispatch and nothing else.
|
||
///
|
||
/// **⌘- and ⌥⌘-arrows never mean anything here.** They are menu key equivalents (Move Left/Right,
|
||
/// Move Up/Down, the lane width pair) and AppKit routes them to the menu before any view sees
|
||
/// them — but only while the item is *enabled*, so a disabled Move Right does deliver ⌘→ here.
|
||
/// Rejecting every combination but plain, ⇧ and ⌥ is what keeps a disabled command from silently
|
||
/// becoming a navigation gesture, and a mistyped text chord from moving the selection.
|
||
private func handleArrow(_ press: KeyPress) -> KeyPress.Result {
|
||
// "All grammar keys inert while a title editor is focused" — and the field owns the arrows
|
||
// as caret movement, so this guard is load-bearing rather than belt over braces.
|
||
guard !store.isEditingInline else { return .ignored }
|
||
guard let direction = Self.direction(of: press.key) else { return .ignored }
|
||
|
||
// Only the four meaningful flags are read: an arrow event also carries `.function` and
|
||
// `.numericPad` on macOS, and testing the whole set for emptiness would reject every press.
|
||
let modifiers = press.modifiers.intersection([.command, .control, .option, .shift])
|
||
let mode: ArrowMode
|
||
if modifiers.isEmpty {
|
||
mode = .step
|
||
} else if modifiers == .shift {
|
||
mode = .extend
|
||
} else if modifiers == .option {
|
||
mode = .jump
|
||
} else {
|
||
return .ignored
|
||
}
|
||
|
||
guard let origin = arrowOrigin() else { return seed(direction, mode) }
|
||
return origin.isLaneDomain
|
||
? laneArrow(direction, mode, from: origin.head)
|
||
: cardArrow(direction, mode, from: origin.head, in: origin.container)
|
||
}
|
||
|
||
private static func direction(of key: KeyEquivalent) -> NavigationMath.Direction? {
|
||
switch key.character {
|
||
case KeyEquivalent.upArrow.character: .up
|
||
case KeyEquivalent.downArrow.character: .down
|
||
case KeyEquivalent.leftArrow.character: .left
|
||
case KeyEquivalent.rightArrow.character: .right
|
||
default: nil
|
||
}
|
||
}
|
||
|
||
/// Where the next arrow steps from, and on which of the board's two levels — `nil` when the
|
||
/// selection names nothing to step from, which is the seed rule's cue.
|
||
///
|
||
/// The head is `TransientBoardState.selectionHead` when it is still in the order list, and
|
||
/// otherwise the selection's **last member in that list** — the same "last in flatten order"
|
||
/// anchor the ⌘N target rule and paste already share. That fallback is what makes a marquee, a
|
||
/// Select All and a foreign reload leave the arrows somewhere sensible without any of them
|
||
/// having to name a cursor.
|
||
///
|
||
/// The **trash's list is its rows**, top to bottom, both kinds interleaved by rank (lanes
|
||
/// rejoined 2026-07-29) — and the trash side is **never the lane domain**, whatever the
|
||
/// selection's kind: the lane domain is the strip's grammar (←/→ walk lanes, ↓ descends into
|
||
/// cards, ⌘←/⌘→ move one), and a trashed lane row is a row in a column, not a lane on the board
|
||
/// ("The trash lane itself is never selectable *as a lane*" — 04 ▸ The trash). Its arrows are the
|
||
/// spatial ones every row gets.
|
||
///
|
||
/// Both lists are the **filtered** board (04 § Search: "arrow nav … read[s] it"), so the
|
||
/// fallback lands on the last *visible* member rather than on a row the query hid.
|
||
private func arrowOrigin() -> (head: ItemID, container: ItemContainer, isLaneDomain: Bool)? {
|
||
let selection = store.selection
|
||
guard !selection.isEmpty else { return nil }
|
||
|
||
let isLaneDomain: Bool
|
||
let list: [ItemID]
|
||
switch selection.container {
|
||
case .board:
|
||
guard let kind = SelectionGrammar.kind(of: selection, in: store.snapshot) else { return nil }
|
||
isLaneDomain = kind == .lane
|
||
list = SelectionGrammar.order(of: kind, in: .board, snapshot: store.snapshot, filter: store.searchFilter)
|
||
case .trash:
|
||
isLaneDomain = false
|
||
list = trashRows
|
||
}
|
||
|
||
if let head = store.transient.selectionHead, list.contains(head) {
|
||
return (head, selection.container, isLaneDomain)
|
||
}
|
||
guard let last = list.last(where: { selection.ids.contains($0) }) else { return nil }
|
||
return (last, selection.container, isLaneDomain)
|
||
}
|
||
|
||
/// **An empty selection seeds at the first lane's first card** (04-interactions.md ▸ Grammar) —
|
||
/// a deterministic origin, so an arrow from nothing always means the same thing.
|
||
///
|
||
/// ⌥←/⌥→ are the exception, and the design states it: "the ⌥-jumps behave as specified
|
||
/// regardless". Those two name an *absolute* destination and need no origin, so they run
|
||
/// unchanged. ⌥↑/⌥↓ are relative to "the current lane", which an empty selection has none of, so
|
||
/// they seed like a plain arrow — which is exactly what makes "two ⌥↑ presses from nothing reach
|
||
/// the lane domain" true: the first seeds, the second escalates.
|
||
private func seed(_ direction: NavigationMath.Direction, _ mode: ArrowMode) -> KeyPress.Result {
|
||
if mode == .jump, direction == .left || direction == .right {
|
||
return jumpToEndLane(direction)
|
||
}
|
||
guard let first = Self.firstCard(scanning: boardLanes, filter: store.searchFilter) else { return .handled }
|
||
replaceSelection(with: first, in: .board)
|
||
return .handled
|
||
}
|
||
|
||
// MARK: Card domain
|
||
|
||
private func cardArrow(
|
||
_ direction: NavigationMath.Direction,
|
||
_ mode: ArrowMode,
|
||
from head: ItemID,
|
||
in container: ItemContainer
|
||
) -> KeyPress.Result {
|
||
switch mode {
|
||
case .step: step(direction, from: head)
|
||
case .extend: extend(direction, from: head)
|
||
case .jump:
|
||
switch direction {
|
||
case .left, .right: jumpToEndLane(direction)
|
||
case .up, .down: jumpWithinContainer(direction, from: head, in: container)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// **Nearest card in the direction, across interior grid columns and lanes** — and across the
|
||
/// live/trash boundary too, since "plain arrows still walk across" (04 ▸ The trash).
|
||
///
|
||
/// Every registered target is a candidate, which is also how the hidden trash stays invisible:
|
||
/// a column that is not drawn registers nothing.
|
||
private func step(_ direction: NavigationMath.Direction, from head: ItemID) -> KeyPress.Result {
|
||
guard let origin = marqueeTargets.targets[head],
|
||
let nextID = NavigationMath.nearest(
|
||
from: origin.frame,
|
||
direction: direction,
|
||
among: marqueeTargets.all
|
||
),
|
||
let next = marqueeTargets.targets[nextID]
|
||
else { return .handled }
|
||
replaceSelection(with: next.id, in: next.container)
|
||
return .handled
|
||
}
|
||
|
||
/// **⇧-arrow extends, and stops at the container boundary** (04 ▸ The trash: "⇧-arrow extension
|
||
/// still stops at the container boundary") — a ⇧-arrow whose next step would cross from live
|
||
/// cards into the trash, or back, is simply inert.
|
||
///
|
||
/// **The kind boundary is no longer one of the stops inside the trash** (re-ruled 2026-07-31):
|
||
/// "⇧-click ranges, ⇧-arrow extension … all sweep every row" there, so a ⇧-arrow from a trash
|
||
/// card onto a lane row extends across it. On the board the kind test stands — the live grammar
|
||
/// is still cards XOR lanes.
|
||
///
|
||
/// The *step* that would cross is what goes inert — the crossing item is never stepped over in
|
||
/// search of a legal one, because that would silently drop the held range for a longer reach
|
||
/// than the user asked for. So the nearest neighbour is computed **unrestricted** and then
|
||
/// tested: a different side (or, on the board, a different kind) means this press does nothing
|
||
/// at all.
|
||
private func extend(_ direction: NavigationMath.Direction, from head: ItemID) -> KeyPress.Result {
|
||
guard let origin = marqueeTargets.targets[head],
|
||
let nextID = NavigationMath.nearest(
|
||
from: origin.frame,
|
||
direction: direction,
|
||
among: marqueeTargets.all
|
||
),
|
||
let next = marqueeTargets.targets[nextID],
|
||
next.container == origin.container,
|
||
next.container == .trash || next.kind == origin.kind
|
||
else { return .handled }
|
||
|
||
// An extension with no anchor makes one of where it started — the keyboard's equivalent of
|
||
// a ⇧-click after a marquee, which the grammar degrades to a plain click for the same
|
||
// reason: a range needs an origin, and the only honest one is the cursor's own position.
|
||
let anchor = store.transient.selectionAnchor ?? head
|
||
guard let ids = SelectionGrammar.range(
|
||
from: anchor,
|
||
to: next.id,
|
||
kind: next.kind,
|
||
in: next.container,
|
||
snapshot: store.snapshot,
|
||
// The span is the *filtered* board's, so a range under a search collects exactly the
|
||
// cards between the two endpoints that are on screen (04 § Search: "ranges … read it").
|
||
filter: store.searchFilter
|
||
) else { return .handled }
|
||
store.select(ids, in: next.container, anchor: anchor, head: next.id)
|
||
return .handled
|
||
}
|
||
|
||
/// **⌥↑/⌥↓ jump to the current container's first/last row** — the lane's cards, or the trash
|
||
/// column's rows when that is where the cursor is.
|
||
///
|
||
/// **⌥↑ escalates into the lane domain** (04 ▸ Grammar, settled — "the keyboard's one entry to
|
||
/// lane selection"): with the lane's first card already the sole selection, the next ⌥↑ selects
|
||
/// the *lane* itself. The trash deliberately never escalates: it "is never selectable as a lane",
|
||
/// so a second ⌥↑ there is simply inert.
|
||
///
|
||
/// **In the trash the jump crosses kinds**, like every other navigation there: the ends it names
|
||
/// are the column's, so ⌥↑ from a card can land on a lane row sitting above it.
|
||
private func jumpWithinContainer(
|
||
_ direction: NavigationMath.Direction,
|
||
from head: ItemID,
|
||
in container: ItemContainer
|
||
) -> KeyPress.Result {
|
||
let siblings: [ItemID]
|
||
var lane: ItemID?
|
||
switch container {
|
||
case .trash:
|
||
siblings = trashRows
|
||
case .board:
|
||
guard let home = store.snapshot.lanes.first(where: { lane in
|
||
lane.cards.contains { $0.id == head }
|
||
}) else { return .handled }
|
||
lane = home.id
|
||
// The container is what the lane is *showing*: a jump to "the lane's first card" under
|
||
// a search means its first surviving card, not one the filter animated out.
|
||
let filter = store.searchFilter
|
||
siblings = home.cards.filter { filter.matches($0) }.map(\.id)
|
||
}
|
||
|
||
guard let target = direction == .up ? siblings.first : siblings.last else { return .handled }
|
||
if direction == .up, target == head, let lane, store.selection.ids == [head] {
|
||
replaceSelection(with: lane, in: .board)
|
||
return .handled
|
||
}
|
||
replaceSelection(with: target, in: container)
|
||
return .handled
|
||
}
|
||
|
||
/// **⌥←/⌥→ to the first/last lane** (04 ▸ Grammar) — landing, in the card domain, on that lane's
|
||
/// first card, since ⌥↑ is the one keyboard entry to lane selection.
|
||
///
|
||
/// **⌥→ reaches the shown trash** first (04 ▸ The trash: "the shown trash is the last container
|
||
/// for card navigation, and ⌥→ jumps to it" — ▸ Grammar names the landing as "its first
|
||
/// **entry**", which is a row of either kind); an empty or hidden column is not a destination, so
|
||
/// the jump falls through to the last lane. Empty lanes are scanned past in both directions —
|
||
/// a jump that landed nowhere because the end lane happens to be empty would be a dead key.
|
||
private func jumpToEndLane(_ direction: NavigationMath.Direction) -> KeyPress.Result {
|
||
if direction == .right, isTrashVisible, let first = trashRows.first {
|
||
replaceSelection(with: first, in: .trash)
|
||
return .handled
|
||
}
|
||
let lanes = boardLanes
|
||
let filter = store.searchFilter
|
||
// A lane the search emptied is scanned past exactly as an empty one is — the jump lands on
|
||
// the first lane that is *showing* a card, which is what the user can see.
|
||
let target = direction == .right
|
||
? Self.firstCard(scanning: lanes.reversed(), filter: filter)
|
||
: Self.firstCard(scanning: lanes, filter: filter)
|
||
guard let target else { return .handled }
|
||
replaceSelection(with: target, in: .board)
|
||
return .handled
|
||
}
|
||
|
||
// MARK: Lane domain
|
||
|
||
/// The arrows with a **lane** selected (04-interactions.md ▸ Grammar, ▸ The map).
|
||
///
|
||
/// - ←/→ move the lane selection one lane, inert at the ends — **and the trash is never reached**
|
||
/// ("with a lane selected, ←/→ and ⌥→ stop at the last real lane"), which falls out for free
|
||
/// from walking the live lane order and nothing else.
|
||
/// - ⇧←/⇧→ extend that selection from the anchor, the same range a ⇧-click would give.
|
||
/// - ↓ descends back into the lane's cards at the first card, ⌥↓ at the last; an empty lane has
|
||
/// nothing to descend into.
|
||
/// - ↑ and ⌥↑ are inert: the lane domain is the top of the hierarchy.
|
||
/// - ⌥←/⌥→ jump to the first/last lane, staying in the lane domain.
|
||
private func laneArrow(
|
||
_ direction: NavigationMath.Direction,
|
||
_ mode: ArrowMode,
|
||
from head: ItemID
|
||
) -> KeyPress.Result {
|
||
let lanes = SelectionGrammar.lanes(in: store.snapshot)
|
||
guard let index = lanes.firstIndex(of: head) else { return .handled }
|
||
|
||
switch (direction, mode) {
|
||
case (.left, .step), (.right, .step), (.left, .extend), (.right, .extend):
|
||
let next = index + (direction == .left ? -1 : 1)
|
||
guard lanes.indices.contains(next) else { return .handled }
|
||
if mode == .step {
|
||
replaceSelection(with: lanes[next], in: .board)
|
||
} else {
|
||
let anchor = store.transient.selectionAnchor ?? head
|
||
guard let ids = SelectionGrammar.range(
|
||
from: anchor,
|
||
to: lanes[next],
|
||
kind: .lane,
|
||
in: .board,
|
||
snapshot: store.snapshot
|
||
) else { return .handled }
|
||
store.select(ids, in: .board, anchor: anchor, head: lanes[next])
|
||
}
|
||
|
||
case (.left, .jump), (.right, .jump):
|
||
guard let target = direction == .left ? lanes.first : lanes.last else { return .handled }
|
||
replaceSelection(with: target, in: .board)
|
||
|
||
case (.down, .step), (.down, .jump):
|
||
guard let lane = store.snapshot.lanes.first(where: { $0.id == head }) else {
|
||
return .handled
|
||
}
|
||
let cards = lane.cards
|
||
guard let target = mode == .jump ? cards.last : cards.first else { return .handled }
|
||
replaceSelection(with: target.id, in: .board)
|
||
|
||
case (.up, _), (.down, .extend):
|
||
// Nothing above the lane domain, and no vertical range within it.
|
||
break
|
||
}
|
||
return .handled
|
||
}
|
||
|
||
// MARK: Shared
|
||
|
||
/// A jump's and a plain step's shared landing: one item, both cursors on it.
|
||
private func replaceSelection(with id: ItemID, in container: ItemContainer) {
|
||
store.select([id], in: container, anchor: id, head: id)
|
||
}
|
||
|
||
/// The first rendered card of the first lane that has one — the scan every "first/last lane"
|
||
/// destination shares, run over the lane order forwards or reversed.
|
||
///
|
||
/// "Rendered" includes the search filter, so a lane whose cards the query all hid is scanned
|
||
/// past like an empty one — `liveCards(in:filter:)`'s membership, one lane at a time.
|
||
private static func firstCard(
|
||
scanning lanes: some Sequence<Lane>,
|
||
filter: SearchFilter = .inactive
|
||
) -> ItemID? {
|
||
for lane in lanes {
|
||
if let card = lane.cards.first(where: { filter.matches($0) }) { return card.id }
|
||
}
|
||
return nil
|
||
}
|
||
}
|