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