The board's fixed grammar keys and the menu-backed chords of 04-interactions.md § Keyboard, per the Command Nexus inventory: - Spatial arrow navigation (NavigationMath.nearest over the marquee registry's frames — one geometry source), walking across interior masonry columns, lanes, and into the shown trash; ⇧-arrows extend via the same range function as ⇧-click and go inert at the liveness and kind boundaries; ⌥-jumps with the ⌥↑ lane-domain escalation and ↓ descent; the empty selection seeds at the first lane's first card; selection scrolls into view. - selectionHead — the navigation cursor beside the anchor, set by every click, moved by every arrow, dropped by the reload vanish rule. - Board ▸ Open Card ⌘↩ (the one command enabled mid-edit: commits the placeholder or rename and opens), Move Up/Move Down ⌥⌘↑/⌥⌘↓ (within-lane sort, gather-then-step, rank-permuting writes in one bracket), Move Left/Move Right ⌘←/⌘→ (sole lane, one slot, never the trash) — all validating and acting off one shared answer. - Delete now selects the Finder-style successor sibling from the pre-write snapshot, so repeated ⌫ walks down a lane; external vanishing still only shrinks the selection. - handleReturn rejects modified Returns; the trash column renders eagerly so every row stays registered for navigation and the marquee. 686 unit tests (27 new). Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
883 lines
46 KiB
Swift
883 lines
46 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).
|
|
/// - **Lane reorder** — the whole title bar is the drag surface (`LaneReorderSession`,
|
|
/// `LaneReorderMath`); the travelling lane rides above its siblings while they show the would-be
|
|
/// order.
|
|
/// - **The rubber band** — a drag from any empty surface sweeps a selection (`MarqueeSession`,
|
|
/// `MarqueeMath`); the strip owns the session and the target registry, and hands both down.
|
|
/// - **The board's fixed grammar keys** (11-command-nexus.md ▸ Fixed grammar keys) — the four
|
|
/// arrows and their ⇧/⌥ modes, Return's create/rename dispatch, ⌫'s tombstone, Escape's step
|
|
/// outward, and Select All. The chorded commands are the menu's (`BoardCommands`); everything
|
|
/// here is a plain key or a grammar modifier, which is exactly the split 04-interactions.md ▸
|
|
/// Configurable bindings draws between what remaps and what does not.
|
|
///
|
|
/// - **The trash quasi-lane** — trailing, one fixed unit, joining and leaving the width division as
|
|
/// View ▸ Show Trash toggles it (`TrashLaneView`, 03-board-ui.md § Trash).
|
|
///
|
|
/// ### What is deliberately not here yet
|
|
///
|
|
/// The toolbar, search, and drag & drop's real machinery (multi-drag, cross-board locality, the
|
|
/// shadow's hold rule) all belong to later milestone cards.
|
|
struct BoardView: View {
|
|
|
|
let store: BoardStore
|
|
|
|
/// How the resize session reaches the host window it grows and shrinks. Injected by
|
|
/// `BoardWindowHost`, which owns the window controller; a closure because the window attaches
|
|
/// after the first body evaluation.
|
|
let window: @MainActor () -> NSWindow?
|
|
|
|
/// The window's purge-alert host — see `TrashConfirmations` for why a menu item's confirmation
|
|
/// has to be presented from here.
|
|
let confirmations: TrashConfirmations
|
|
|
|
/// Opens a card's window — ⌘↩'s second half (04-interactions.md ▸ Grammar). A closure from
|
|
/// `BoardWindowHost` rather than an `openWindow` call here, because building a `CardWindowRef`
|
|
/// needs the board's own window ref, which is the host's identity and not the board's.
|
|
let openCard: (ItemID) -> Void
|
|
|
|
/// The app-wide quick-style recents, for the board-anchored style editor (03-board-ui.md §
|
|
/// Styling ▸ Controls).
|
|
@Environment(AppModel.self) private var appModel
|
|
|
|
/// One resize at a time, per window. `@State` so it lives exactly as long as this board window's
|
|
/// view does, which is the interaction's whole lifetime.
|
|
@State private var resize = LaneResizeSession()
|
|
|
|
/// One reorder at a time, per window — same lifetime, same reasoning.
|
|
@State private var reorder = LaneReorderSession()
|
|
|
|
/// One drag out of the trash at a time, per window — same lifetime again.
|
|
@State private var trashDrag = TrashDragSession()
|
|
|
|
/// One rubber band at a time, per window (`MarqueeSession`).
|
|
@State private var marquee = MarqueeSession()
|
|
|
|
/// Where every sweepable item is drawn, in strip coordinates. Owned here because the band is —
|
|
/// the cards and trash rows only *register* into it (`MarqueeTargetRegistry`).
|
|
@State private var marqueeTargets = MarqueeTargetRegistry()
|
|
|
|
/// The name of the strip's coordinate space, which is what a drop out of the trash is resolved
|
|
/// in: `LaneLayoutMath.laneIndex` reads an x measured from the strip's leading edge, outer margin
|
|
/// included, and no global or lane-local space is that.
|
|
static let stripSpace = "board-strip"
|
|
|
|
/// Reduce Motion, for the transitions and the reflow curve below (10-accessibility.md). Read
|
|
/// from the environment here and handed to `Motion`, which owns what "reduced" means for each.
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
|
|
/// Whether the strip holds keyboard focus, which is what makes the grammar keys arrive. Restored
|
|
/// deliberately whenever an inline editor closes: the field that had focus is gone, and Return
|
|
/// must go back to meaning create/rename rather than nothing at all.
|
|
@FocusState private var isBoardFocused: Bool
|
|
|
|
/// The inter-lane gap, and the strip's outer margin — one number, because the standard-width
|
|
/// formula counts `units + 1` of them (03-board-ui.md § Layout; `LaneLayoutMath.standardWidth`).
|
|
private let spacing: CGFloat = 12
|
|
|
|
var body: some View {
|
|
GeometryReader { viewport in
|
|
let lanes = liveLanes
|
|
// 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 viewport 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 viewport formula yields once the
|
|
// session ends — the handoff is seamless (see `LaneResizeSession`).
|
|
let standard = resize.isActive
|
|
? resize.standard
|
|
: LaneLayoutMath.standardWidth(
|
|
stripWidth: viewport.size.width,
|
|
// The trash's one fixed unit joins the division **only while shown**, which is
|
|
// 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 lane add's behaviour, exactly.
|
|
totalUnits: LaneLayoutMath.totalUnits(of: lanes, trashUnits: isTrashVisible ? 1 : 0),
|
|
gap: spacing)
|
|
// The drag's drop proposal, computed once because two things read it: the order the
|
|
// strip shows, and the key its reflow animates on. 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).
|
|
let move = proposal(among: lanes, standard: standard)
|
|
// The lanes in the order the strip should *show* them: their snapshot order at rest, and
|
|
// the drag's would-be order while a reorder is in flight — which is how the siblings
|
|
// reflow to make room. A drag whose lane has vanished from the snapshot proposes nothing
|
|
// and shows the plain order; its release then cancels ("an emptied drag cancels itself").
|
|
let shown = move.map { LaneReorderMath.reordered(lanes, from: $0.from, to: $0.to) } ?? lanes
|
|
ZStack(alignment: .topLeading) {
|
|
backdrop
|
|
laneStrip(shown, standard: standard, move: move)
|
|
}
|
|
.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 a drop out of the trash 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))
|
|
}
|
|
.background(boardBackground)
|
|
.trashPurgeAlert(store: store, confirmations: confirmations)
|
|
// The board's own anchor for the Style… popover — the surface a board-targeted session hangs
|
|
// off, since the board has no item to attach to (`styleEditorPresentation`'s `nil` anchor).
|
|
.popover(isPresented: styleEditorPresentation(store, anchor: nil), arrowEdge: .top) {
|
|
StyleEditorPopover(store: store, recents: appModel.styleRecents)
|
|
}
|
|
// The board is a focus target so the grammar keys reach it at all. The focus *ring* is off:
|
|
// the strip is the window's content, not a control, and a rectangle around the whole board
|
|
// would read as an error state.
|
|
.focusable()
|
|
.focusEffectDisabled()
|
|
.focused($isBoardFocused)
|
|
.onAppear { isBoardFocused = true }
|
|
.onChange(of: store.isEditingInline) { _, editing in
|
|
// An editor took focus and has now given it back. Without this the strip stays unfocused
|
|
// after every rename and Return silently stops working.
|
|
if !editing { isBoardFocused = true }
|
|
}
|
|
.onKeyPress(keys: [.return], phases: .down) { handleReturn($0) }
|
|
.onKeyPress(.escape) { handleEscape() }
|
|
.onKeyPress(keys: [.delete], phases: .down) { handleDelete($0) }
|
|
// **The arrows** (04-interactions.md ▸ Grammar), on `.down` *and* `.repeat`: holding an
|
|
// arrow must walk the board, and a handler registered for `.down` alone sees the first
|
|
// press only.
|
|
.onKeyPress(
|
|
keys: [.upArrow, .downArrow, .leftArrow, .rightArrow],
|
|
phases: [.down, .repeat]
|
|
) { handleArrow($0) }
|
|
// **Select All** (04-interactions.md ▸ The map). Edit ▸ Select All is the standard menu
|
|
// item and it dispatches `selectAll:` down the responder chain, so the board answers it as a
|
|
// responder rather than growing a second menu item with the same title — which titles-are-API
|
|
// forbids outright (04 ▸ Configurable bindings). A focused text field consumes it first, so
|
|
// ⌘A inside an inline editor stays text selection with no guard needed here.
|
|
.onCommand(#selector(NSText.selectAll(_:))) { store.selectAll() }
|
|
}
|
|
|
|
// MARK: - The strip's layers
|
|
|
|
/// The empty surface behind the lanes: a plain click clears the selection, a drag rubber-bands.
|
|
///
|
|
/// `Color.clear` with a `contentShape` rather than a real fill — the board's *painted*
|
|
/// background is `boardBackground`, outside the geometry reader, and this layer exists only to
|
|
/// be hit. Modified clicks are deliberately no-ops: ⌘ and ⇧ on the backdrop name no target, and
|
|
/// Finder's own desktop behaves the same way.
|
|
private var backdrop: some View {
|
|
Color.clear
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
guard ClickModifier.current == .plain else { return }
|
|
store.clearSelection()
|
|
}
|
|
.simultaneousGesture(marqueeControl.gesture(side: .live))
|
|
}
|
|
|
|
/// The lanes themselves, plus the trash column when it is shown.
|
|
@ViewBuilder
|
|
private func laneStrip(_ shown: [Lane], standard: CGFloat, move: (from: Int, to: Int)?) -> some View {
|
|
HStack(alignment: .top, spacing: spacing) {
|
|
ForEach(Array(shown.enumerated()), id: \.element.id) { position, lane in
|
|
laneSlot(lane, at: position, among: shown, standard: standard)
|
|
// "Appear/disappear is scale + fade … lanes ~0.9" (03-board-ui.md § Motion).
|
|
// A create, a delete and a Put Back all reach the strip as a lane arriving in
|
|
// or leaving this `ForEach`; whether that *performs* is decided upstream, at
|
|
// the reload that carried it (`Motion.reloadAnimates`) — a transition with no
|
|
// animated transaction around it is simply an appearance.
|
|
.transition(Motion.laneTransition(reduced: reduceMotion))
|
|
}
|
|
if isTrashVisible {
|
|
// Trailing, always — the quasi-lane has no position of its own to lose, which is
|
|
// also why it never appears in the reorder proposal's inputs (those are built
|
|
// from `liveLanes`).
|
|
TrashLaneView(
|
|
store: store,
|
|
confirmations: confirmations,
|
|
drag: TrashRowDrag { x in laneUnder(x: x, standard: standard) },
|
|
dragSession: trashDrag,
|
|
marquee: marqueeControl
|
|
)
|
|
.frame(width: LaneLayoutMath.slotWidth(units: 1, standard: standard, gap: spacing))
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
// It arrives and leaves like a lane, because that is what it looks like — the
|
|
// column scales and fades while every real lane compresses to make room for its
|
|
// unit (03-board-ui.md § Motion, § Trash's re-divide). The transaction is the
|
|
// menu toggle's (`ShowTrashCommand`).
|
|
.transition(Motion.laneTransition(reduced: reduceMotion))
|
|
}
|
|
}
|
|
// The drag's reflow-to-make-room, keyed on the **drop proposal** and nothing else
|
|
// (03-board-ui.md § Motion: transactions are keyed narrowly, "on the drag's drop
|
|
// proposal … never on broad state"). The travelling lane's own offset changes on every
|
|
// pointer sample and none of those samples touch this value, so the replica keeps
|
|
// tracking the cursor 1:1 — which is the same bullet's other half. At the instant the
|
|
// proposal ticks, the lane's slot and its offset move by equal and opposite amounts, so
|
|
// animating both under one curve is what keeps it pinned under the cursor.
|
|
//
|
|
// 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: move?.to)
|
|
}
|
|
|
|
/// The rubber band itself: a translucent accent fill with a hairline border, in strip
|
|
/// coordinates and **never animated** (03-board-ui.md § Motion — the marquee "tracks the cursor
|
|
/// 1:1", and an eased band visibly lags the mouse).
|
|
///
|
|
/// Hit-testing off, because the band is feedback: the drag that draws it is already recognised,
|
|
/// and a rectangle that swallowed clicks would eat the release.
|
|
@ViewBuilder
|
|
private var marqueeBand: some View {
|
|
if let rect = marquee.rect {
|
|
Rectangle()
|
|
.fill(Color.accentColor.opacity(0.12))
|
|
.frame(width: rect.width, height: rect.height)
|
|
.overlay(Rectangle().strokeBorder(Color.accentColor.opacity(0.5), lineWidth: 1))
|
|
.offset(x: rect.minX, y: rect.minY)
|
|
.allowsHitTesting(false)
|
|
}
|
|
}
|
|
|
|
/// What the strip lends its empty surfaces and its sweepable items — the band's session, the
|
|
/// registry, and the store it selects into (`MarqueeControl`).
|
|
private var marqueeControl: MarqueeControl {
|
|
MarqueeControl(session: marquee, registry: marqueeTargets, store: store)
|
|
}
|
|
|
|
// MARK: - Styling
|
|
|
|
/// The board's `background`, painting "the board window's content background (the surface behind
|
|
/// and between lanes)" (03-board-ui.md § Styling ▸ Capabilities).
|
|
///
|
|
/// Unlike the lane band and the card stripe this one is a **fill**, because at board level that
|
|
/// is what the design asks for — and it is why the board is the level 10-accessibility.md binds
|
|
/// its ≥ 4.5:1 rule to: text does sit on it. That runtime contrast computation (a hex background's
|
|
/// text colour, recomputed against the composited backdrop on appearance change) is not this
|
|
/// card's — what ships here is the palette path, whose twelve pairs are AA-verified at design
|
|
/// time.
|
|
///
|
|
/// A value that resolves to nothing paints nothing, so the window keeps the standard background:
|
|
/// the same lenient degrade as the other two levels, and the bytes stay as written.
|
|
@ViewBuilder
|
|
private var boardBackground: some View {
|
|
if let color = Palette.color(for: store.snapshot.background) {
|
|
color
|
|
}
|
|
}
|
|
|
|
// MARK: - Lanes
|
|
|
|
/// One lane's strip slot, plus its trailing grab strip.
|
|
///
|
|
/// Normally a plain `LaneView` sized to its unit count's slot width (a wide lane swallows the
|
|
/// interior gaps it spans). While THIS lane is being resized the slot holds two layers, kept
|
|
/// structurally stable so the `LaneView` never loses identity — its scroll position, its
|
|
/// masonry cache — across the drag:
|
|
///
|
|
/// • a shadow at the SNAPPED slot width, full strip height, behind the lane — the resting
|
|
/// footprint the siblings and the window are already aligned to;
|
|
/// • the live `LaneView` in front at `liveWidth`, which tracks the cursor and so overflows
|
|
/// (drawing over the right neighbour, hence the slot's `zIndex(1)`) or underfills the shadow
|
|
/// between ticks.
|
|
///
|
|
/// The outer frame is always the snapped slot width, so the `HStack` lays the other lanes out
|
|
/// off the tidy snapped layout regardless of the live overflow.
|
|
///
|
|
/// While this lane is being **reordered** the slot instead keeps its resting size and travels:
|
|
/// the offset is the gap between where the pointer has carried it and where it would rest under
|
|
/// the current proposal, so it tracks the cursor 1:1 while its siblings sit in the would-be
|
|
/// order beneath it (`zIndex(2)`, above even a resize).
|
|
@ViewBuilder
|
|
private func laneSlot(_ lane: Lane, at position: Int, among shown: [Lane], standard: CGFloat) -> some View {
|
|
let resizing = resize.isResizing(lane.id)
|
|
let dragging = reorder.isDragging(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 {
|
|
LaneResizeShadow()
|
|
.frame(width: slotWidth)
|
|
.frame(maxHeight: .infinity)
|
|
.allowsHitTesting(false)
|
|
}
|
|
// 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,
|
|
reorder: reorder,
|
|
headerDrag: headerDrag(at: position, among: shown, standard: standard),
|
|
marquee: marqueeControl,
|
|
openCard: openCard
|
|
)
|
|
.frame(width: resizing ? resize.liveWidth : slotWidth, alignment: .leading)
|
|
}
|
|
// The drop highlight for a drag out of the trash: the lane the pointer is currently over.
|
|
// Feedback lives on the *target* rather than on a travelling replica, because the replica —
|
|
// its lift, its settle, the copy/move badge — is m5's drag card (03-board-ui.md § Motion).
|
|
.overlay {
|
|
if trashDrag.isTarget(lane.id) {
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.strokeBorder(Color.accentColor, lineWidth: 2)
|
|
.allowsHitTesting(false)
|
|
}
|
|
}
|
|
.frame(width: slotWidth, alignment: .topLeading)
|
|
.offset(x: dragging ? travelOffset(at: position, among: shown, standard: standard) : 0)
|
|
.opacity(dragging ? 0.9 : 1)
|
|
.zIndex(dragging ? 2 : (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 reorder in flight: two drags mutating one strip layout is not a state this
|
|
// view has a meaning for.
|
|
.disabled(store.isReadOnly || store.isEditingInline || reorder.isActive)
|
|
}
|
|
}
|
|
|
|
/// The lanes the strip lays out, in snapshot order. **Tombstoned lanes render nowhere here** —
|
|
/// 03-board-ui.md § Trash collapses each into a single restorable entry in the trash quasi-lane
|
|
/// (a later card), and a lane that is not on the board consumes none of the window's width.
|
|
private var liveLanes: [Lane] {
|
|
store.snapshot.lanes.filter { !$0.isDeleted }
|
|
}
|
|
|
|
// MARK: - Trash
|
|
|
|
/// Whether the trash quasi-lane is on screen — transient, board-scoped, hidden on every open
|
|
/// (03-board-ui.md § Trash ▸ Visibility). Read in two places (the unit total and the slot), so it
|
|
/// gets a name rather than being spelled twice.
|
|
private var isTrashVisible: Bool {
|
|
store.transient.isTrashVisible
|
|
}
|
|
|
|
/// The live lane under `x` in strip coordinates, or `nil` — the strip's half of drag-to-restore.
|
|
///
|
|
/// Re-derived against `liveLanes` at gesture time rather than captured at drag start, which is
|
|
/// 04-interactions.md ▸ Drag and drop's re-grounding rule: a foreign reload that adds or
|
|
/// tombstones a lane mid-drag just moves the zones, and the next proposal targets the board as it
|
|
/// now is. A tombstoned lane is never a drop target because it is never in this list.
|
|
private func laneUnder(x: CGFloat, standard: CGFloat) -> ItemID? {
|
|
let lanes = liveLanes
|
|
guard let index = LaneLayoutMath.laneIndex(
|
|
atX: x,
|
|
unitCounts: unitCounts(of: lanes),
|
|
standard: standard,
|
|
gap: spacing
|
|
) else { return nil }
|
|
return lanes.indices.contains(index) ? lanes[index].id : nil
|
|
}
|
|
|
|
// MARK: - Reorder
|
|
|
|
/// Where the dragged lane sits in `lanes` and where it would land — `nil` when no reorder is in
|
|
/// flight, or when the lane it is carrying is no longer on the board.
|
|
///
|
|
/// Called **once** per render, because a proposal is two things at once and they must be the
|
|
/// same answer: the order the strip shows, and the narrow key its reflow animates on (see
|
|
/// `body`). It is asked again at release, against the snapshot as it is by then
|
|
/// (`commitReorder`).
|
|
private func proposal(among lanes: [Lane], standard: CGFloat) -> (from: Int, to: Int)? {
|
|
guard let id = reorder.laneID, let from = lanes.firstIndex(where: { $0.id == id }) else { return nil }
|
|
let to = LaneReorderMath.proposedIndex(
|
|
unitCounts: unitCounts(of: lanes),
|
|
draggedIndex: from,
|
|
dragCentreX: reorder.centre,
|
|
standard: standard,
|
|
gap: spacing
|
|
)
|
|
return (from, to)
|
|
}
|
|
|
|
/// How far the travelling lane is drawn from the slot it would rest in — the pointer's position
|
|
/// minus the proposal's. Zero at the instant a tick lands, growing again as the pointer moves
|
|
/// on, which is what makes the replica read as *held* rather than as snapping.
|
|
private func travelOffset(at position: Int, among shown: [Lane], standard: CGFloat) -> CGFloat {
|
|
reorder.centre - LaneReorderMath.centre(
|
|
ofLaneAt: position,
|
|
unitCounts: unitCounts(of: shown),
|
|
standard: standard,
|
|
gap: spacing
|
|
)
|
|
}
|
|
|
|
/// The strip's half of a lane header's drag: where the lane rests now, and what a release means.
|
|
private func headerDrag(at position: Int, among shown: [Lane], standard: CGFloat) -> LaneHeaderDrag {
|
|
LaneHeaderDrag(
|
|
startCentre: {
|
|
LaneReorderMath.centre(
|
|
ofLaneAt: position,
|
|
unitCounts: unitCounts(of: shown),
|
|
standard: standard,
|
|
gap: spacing
|
|
)
|
|
},
|
|
commit: { commitReorder(standard: standard) }
|
|
)
|
|
}
|
|
|
|
/// Releases the drag: re-derive the proposal against the snapshot **as it is now** and write it.
|
|
///
|
|
/// Re-deriving rather than trusting the last rendered proposal is 04-interactions.md ▸ Drag and
|
|
/// drop's re-grounding rule at its most consequential moment: a reload that landed between the
|
|
/// last render and the release must not be written over. A lane that vanished in that window
|
|
/// yields no proposal and the release simply cancels — "release with no valid proposal cancels;
|
|
/// items return, nothing is written".
|
|
///
|
|
/// `BoardStore.moveLane` owns the rest, the unchanged-index no-op included.
|
|
private func commitReorder(standard: CGFloat) {
|
|
defer { reorder.end() }
|
|
guard let id = reorder.laneID,
|
|
let (_, to) = proposal(among: liveLanes, standard: standard)
|
|
else { return }
|
|
store.moveLane(id, toIndex: to)
|
|
}
|
|
|
|
private func unitCounts(of lanes: [Lane]) -> [Int] {
|
|
lanes.map { LaneLayoutMath.displayUnits(of: $0) }
|
|
}
|
|
|
|
// MARK: - Grammar keys
|
|
|
|
/// **Return**, narrowly (04-interactions.md ▸ Grammar): a sole selected live card begins an
|
|
/// inline rename, a sole selected live lane begins a new-card placeholder at its bottom, and
|
|
/// everything else is ignored — a multi-card selection is explicitly inert, and a lane's rename
|
|
/// path is Board ▸ Rename precisely because Return on a lane creates.
|
|
///
|
|
/// Inert while an inline editor is open: "all grammar keys inert while a title editor is
|
|
/// focused". The field consumes Return itself, so this guard is belt over braces — but the belt
|
|
/// matters, because a stray Return reaching here mid-edit would open a *second* editor.
|
|
private func handleReturn(_ press: KeyPress) -> KeyPress.Result {
|
|
// **Plain Return only**, the delete handler's rule for its reason. ⌘↩ belongs to Board ▸
|
|
// Open Card and AppKit routes it to the menu first — but only while that item is *enabled*,
|
|
// and a disabled one lets the chord fall through to here. ⌥↩ and ⇧↩ are nobody's key
|
|
// equivalent at all. Neither may open a rename or a placeholder.
|
|
guard press.modifiers.intersection([.command, .option, .control, .shift]).isEmpty else {
|
|
return .ignored
|
|
}
|
|
guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
|
|
let selection = store.selection
|
|
guard selection.liveness == .live,
|
|
selection.ids.count == 1,
|
|
let id = selection.ids.first,
|
|
let target = BoardStore.liveItem(id, in: store.snapshot)
|
|
else { return .ignored }
|
|
|
|
if target.cardID == nil {
|
|
store.transient.beginPlaceholder(inLane: target.laneID)
|
|
} else {
|
|
store.transient.beginRename(of: id, currentTitle: target.title)
|
|
}
|
|
return .handled
|
|
}
|
|
|
|
/// **Plain ⌫ tombstones the live selection** — "a plain-key synonym of File ▸ Delete, kept
|
|
/// grammar so no second 'Delete' title exists" (11-command-nexus.md; 04-interactions.md ▸ The
|
|
/// map).
|
|
///
|
|
/// Deliberately **live-only**: the nexus scopes this key to a live selection, and ⌫'s trash-side
|
|
/// role belongs to the ⌘⌫ twins, not to the bare key. A tombstoned selection is therefore inert
|
|
/// here — Put Back is a chord.
|
|
///
|
|
/// Inert while an inline editor is open, like every grammar key: the field owns ⌫ as backspace,
|
|
/// and a stray one reaching the board mid-edit would delete the item being renamed.
|
|
private func handleDelete(_ press: KeyPress) -> KeyPress.Result {
|
|
// **Plain ⌫, spelled out.** The modified chords belong to the menu — ⌘⌫ (Delete / Put Back),
|
|
// ⌥⌘⌫ (Delete Immediately), ⇧⌘⌫ (Empty Trash…) — and AppKit routes a key equivalent to the
|
|
// menu before the view sees it. But ⌥⌫ and ⌃⌫ are nobody's key equivalent, and a fall-through
|
|
// that tombstoned the selection on a mistyped text-editing chord would be exactly the kind of
|
|
// accident 04-interactions.md's fixed grammar is careful to avoid.
|
|
guard press.modifiers.intersection([.command, .option, .control, .shift]).isEmpty else {
|
|
return .ignored
|
|
}
|
|
guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
|
|
let selection = store.selection
|
|
guard selection.liveness == .live, !selection.isEmpty else { return .ignored }
|
|
store.deleteSelection()
|
|
return .handled
|
|
}
|
|
|
|
/// **Escape steps outward one layer per press** (04 ▸ Grammar): abandon an open editor, else
|
|
/// clear the selection.
|
|
///
|
|
/// The middle step — clearing an active search and returning focus to the board — is m5's, and
|
|
/// it slots between these two once the search field exists.
|
|
///
|
|
/// The editors handle Escape themselves while they hold focus; this branch is the outer net for
|
|
/// the case where focus has drifted off the field with an editor still open, and it abandons
|
|
/// both kinds because at most one can be open at a time.
|
|
private func handleEscape() -> KeyPress.Result {
|
|
if store.isEditingInline {
|
|
store.transient.discardPlaceholder()
|
|
store.transient.discardRename()
|
|
return .handled
|
|
}
|
|
guard !store.selection.isEmpty else { return .ignored }
|
|
store.clearSelection()
|
|
return .handled
|
|
}
|
|
|
|
// MARK: - The arrows
|
|
|
|
/// What modifier an arrow carried, reduced to the three meanings the grammar gives it — the
|
|
/// keyboard's `ClickModifier`.
|
|
private enum ArrowMode {
|
|
/// Plain: spatial navigation, replacing the selection.
|
|
case step
|
|
/// ⇧: extend the range from the anchor.
|
|
case extend
|
|
/// ⌥: jump to an end (04-interactions.md ▸ Grammar's "⌥-arrows jump").
|
|
case jump
|
|
}
|
|
|
|
/// **The arrow grammar's one door** (04-interactions.md ▸ Grammar; 11-command-nexus.md ▸ Fixed
|
|
/// grammar keys).
|
|
///
|
|
/// The handlers below are deliberately thin over pure functions — `NavigationMath` for the
|
|
/// geometry, `SelectionGrammar` for the order lists and the ranges — so what is written here is
|
|
/// dispatch and nothing else.
|
|
///
|
|
/// **⌘- and ⌥⌘-arrows never mean anything here.** They are menu key equivalents (Move Left/Right,
|
|
/// Move Up/Down, the lane width pair) and AppKit routes them to the menu before any view sees
|
|
/// them — but only while the item is *enabled*, so a disabled Move Right does deliver ⌘→ here.
|
|
/// Rejecting every combination but plain, ⇧ and ⌥ is what keeps a disabled command from silently
|
|
/// becoming a navigation gesture, and a mistyped text chord from moving the selection.
|
|
private func handleArrow(_ press: KeyPress) -> KeyPress.Result {
|
|
// "All grammar keys inert while a title editor is focused" — and the field owns the arrows
|
|
// as caret movement, so this guard is load-bearing rather than belt over braces.
|
|
guard !store.isEditingInline else { return .ignored }
|
|
guard let direction = Self.direction(of: press.key) else { return .ignored }
|
|
|
|
// Only the four meaningful flags are read: an arrow event also carries `.function` and
|
|
// `.numericPad` on macOS, and testing the whole set for emptiness would reject every press.
|
|
let modifiers = press.modifiers.intersection([.command, .control, .option, .shift])
|
|
let mode: ArrowMode
|
|
if modifiers.isEmpty {
|
|
mode = .step
|
|
} else if modifiers == .shift {
|
|
mode = .extend
|
|
} else if modifiers == .option {
|
|
mode = .jump
|
|
} else {
|
|
return .ignored
|
|
}
|
|
|
|
guard let origin = arrowOrigin() else { return seed(direction, mode) }
|
|
return origin.isLaneDomain
|
|
? laneArrow(direction, mode, from: origin.head)
|
|
: cardArrow(direction, mode, from: origin.head, on: origin.side)
|
|
}
|
|
|
|
private static func direction(of key: KeyEquivalent) -> NavigationMath.Direction? {
|
|
switch key.character {
|
|
case KeyEquivalent.upArrow.character: .up
|
|
case KeyEquivalent.downArrow.character: .down
|
|
case KeyEquivalent.leftArrow.character: .left
|
|
case KeyEquivalent.rightArrow.character: .right
|
|
default: nil
|
|
}
|
|
}
|
|
|
|
/// Where the next arrow steps from, and on which of the board's two levels — `nil` when the
|
|
/// selection names nothing to step from, which is the seed rule's cue.
|
|
///
|
|
/// The head is `TransientBoardState.selectionHead` when it is still in the order list, and
|
|
/// otherwise the selection's **last member in that list** — the same "last in flatten order"
|
|
/// anchor the ⌘N target rule and paste already share. That fallback is what makes a marquee, a
|
|
/// Select All and a foreign reload leave the arrows somewhere sensible without any of them
|
|
/// having to name a cursor.
|
|
///
|
|
/// The **trash's list is both kinds interleaved** (`TrashModel.entries`), because "arrows walk
|
|
/// every trash entry in its sorted order — card and lane entries alike" (04 ▸ The trash). The
|
|
/// per-kind lists are the *range*'s business, not the walk's.
|
|
private func arrowOrigin() -> (head: ItemID, side: Liveness, isLaneDomain: Bool)? {
|
|
let selection = store.selection
|
|
guard !selection.isEmpty else { return nil }
|
|
|
|
let isLaneDomain: Bool
|
|
let list: [ItemID]
|
|
switch selection.liveness {
|
|
case .live:
|
|
guard let kind = SelectionGrammar.kind(of: selection, in: store.snapshot) else { return nil }
|
|
isLaneDomain = kind == .lane
|
|
list = SelectionGrammar.order(of: kind, on: .live, in: store.snapshot)
|
|
case .trashed:
|
|
isLaneDomain = false
|
|
list = TrashModel.entries(of: store.snapshot).map(\.id)
|
|
}
|
|
|
|
if let head = store.transient.selectionHead, list.contains(head) {
|
|
return (head, selection.liveness, isLaneDomain)
|
|
}
|
|
guard let last = list.last(where: { selection.ids.contains($0) }) else { return nil }
|
|
return (last, selection.liveness, isLaneDomain)
|
|
}
|
|
|
|
/// **An empty selection seeds at the first lane's first card** (04-interactions.md ▸ Grammar) —
|
|
/// a deterministic origin, so an arrow from nothing always means the same thing.
|
|
///
|
|
/// ⌥←/⌥→ are the exception, and the design states it: "the ⌥-jumps behave as specified
|
|
/// regardless". Those two name an *absolute* destination and need no origin, so they run
|
|
/// unchanged. ⌥↑/⌥↓ are relative to "the current lane", which an empty selection has none of, so
|
|
/// they seed like a plain arrow — which is exactly what makes "two ⌥↑ presses from nothing reach
|
|
/// the lane domain" true: the first seeds, the second escalates.
|
|
private func seed(_ direction: NavigationMath.Direction, _ mode: ArrowMode) -> KeyPress.Result {
|
|
if mode == .jump, direction == .left || direction == .right {
|
|
return jumpToEndLane(direction)
|
|
}
|
|
guard let first = Self.firstCard(scanning: liveLanes) else { return .handled }
|
|
replaceSelection(with: first, on: .live)
|
|
return .handled
|
|
}
|
|
|
|
// MARK: Card domain
|
|
|
|
private func cardArrow(
|
|
_ direction: NavigationMath.Direction,
|
|
_ mode: ArrowMode,
|
|
from head: ItemID,
|
|
on side: Liveness
|
|
) -> KeyPress.Result {
|
|
switch mode {
|
|
case .step: step(direction, from: head)
|
|
case .extend: extend(direction, from: head)
|
|
case .jump:
|
|
switch direction {
|
|
case .left, .right: jumpToEndLane(direction)
|
|
case .up, .down: jumpWithinContainer(direction, from: head, on: side)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// **Nearest card in the direction, across interior grid columns and lanes** — and across the
|
|
/// live/trash boundary too, since "plain arrows still walk across" (04 ▸ The trash).
|
|
///
|
|
/// Every registered target is a candidate, which is also how the hidden trash stays invisible:
|
|
/// a column that is not drawn registers nothing.
|
|
private func step(_ direction: NavigationMath.Direction, from head: ItemID) -> KeyPress.Result {
|
|
guard let origin = marqueeTargets.targets[head],
|
|
let nextID = NavigationMath.nearest(
|
|
from: origin.frame,
|
|
direction: direction,
|
|
among: marqueeTargets.all
|
|
),
|
|
let next = marqueeTargets.targets[nextID]
|
|
else { return .handled }
|
|
replaceSelection(with: next.id, on: next.side)
|
|
return .handled
|
|
}
|
|
|
|
/// **⇧-arrow extends, and stops at both boundaries** (04 ▸ The trash, settled): "a ⇧-arrow whose
|
|
/// next step would cross from live cards into the trash (or back), or from card entries onto a
|
|
/// lane entry within it, is simply inert".
|
|
///
|
|
/// The *step* that would cross is what goes inert — the crossing item is never stepped over in
|
|
/// search of a legal one, because that would silently drop the held range for a longer reach
|
|
/// than the user asked for. So the nearest neighbour is computed **unrestricted** and then
|
|
/// tested: a different side or a different kind means this press does nothing at all.
|
|
private func extend(_ direction: NavigationMath.Direction, from head: ItemID) -> KeyPress.Result {
|
|
guard let origin = marqueeTargets.targets[head],
|
|
let nextID = NavigationMath.nearest(
|
|
from: origin.frame,
|
|
direction: direction,
|
|
among: marqueeTargets.all
|
|
),
|
|
let next = marqueeTargets.targets[nextID],
|
|
next.side == origin.side,
|
|
next.kind == origin.kind
|
|
else { return .handled }
|
|
|
|
// An extension with no anchor makes one of where it started — the keyboard's equivalent of
|
|
// a ⇧-click after a marquee, which the grammar degrades to a plain click for the same
|
|
// reason: a range needs an origin, and the only honest one is the cursor's own position.
|
|
let anchor = store.transient.selectionAnchor ?? head
|
|
guard let ids = SelectionGrammar.range(
|
|
from: anchor,
|
|
to: next.id,
|
|
kind: next.kind,
|
|
on: next.side,
|
|
in: store.snapshot
|
|
) else { return .handled }
|
|
store.select(ids, liveness: next.side, anchor: anchor, head: next.id)
|
|
return .handled
|
|
}
|
|
|
|
/// **⌥↑/⌥↓ jump to the current container's first/last card** — the lane's, or the trash
|
|
/// quasi-lane's when that is where the cursor is.
|
|
///
|
|
/// **⌥↑ escalates into the lane domain** (04 ▸ Grammar, settled — "the keyboard's one entry to
|
|
/// lane selection"): with the lane's first card already the sole selection, the next ⌥↑ selects
|
|
/// the *lane* itself. The trash deliberately never escalates: it "is never selectable as a lane",
|
|
/// so a second ⌥↑ there is simply inert.
|
|
private func jumpWithinContainer(
|
|
_ direction: NavigationMath.Direction,
|
|
from head: ItemID,
|
|
on side: Liveness
|
|
) -> KeyPress.Result {
|
|
let container: [ItemID]
|
|
var lane: ItemID?
|
|
switch side {
|
|
case .trashed:
|
|
container = TrashModel.entries(of: store.snapshot).map(\.id)
|
|
case .live:
|
|
guard let home = store.snapshot.lanes.first(where: { lane in
|
|
!lane.isDeleted && lane.cards.contains { $0.id == head && !$0.isDeleted }
|
|
}) else { return .handled }
|
|
lane = home.id
|
|
container = home.cards.filter { !$0.isDeleted }.map(\.id)
|
|
}
|
|
|
|
guard let target = direction == .up ? container.first : container.last else { return .handled }
|
|
if direction == .up, target == head, let lane, store.selection.ids == [head] {
|
|
replaceSelection(with: lane, on: .live)
|
|
return .handled
|
|
}
|
|
replaceSelection(with: target, on: side)
|
|
return .handled
|
|
}
|
|
|
|
/// **⌥←/⌥→ to the first/last lane** (04 ▸ Grammar) — landing, in the card domain, on that lane's
|
|
/// first card, since ⌥↑ is the one keyboard entry to lane selection.
|
|
///
|
|
/// **⌥→ reaches the shown trash** first (04 ▸ The trash: "the shown trash is the last container
|
|
/// for card navigation, and ⌥→ jumps to it"); an empty or hidden column is not a destination, so
|
|
/// the jump falls through to the last lane. Empty lanes are scanned past in both directions —
|
|
/// a jump that landed nowhere because the end lane happens to be empty would be a dead key.
|
|
private func jumpToEndLane(_ direction: NavigationMath.Direction) -> KeyPress.Result {
|
|
if direction == .right, isTrashVisible, let first = TrashModel.entries(of: store.snapshot).first {
|
|
replaceSelection(with: first.id, on: .trashed)
|
|
return .handled
|
|
}
|
|
let lanes = liveLanes
|
|
let target = direction == .right
|
|
? Self.firstCard(scanning: lanes.reversed())
|
|
: Self.firstCard(scanning: lanes)
|
|
guard let target else { return .handled }
|
|
replaceSelection(with: target, on: .live)
|
|
return .handled
|
|
}
|
|
|
|
// MARK: Lane domain
|
|
|
|
/// The arrows with a **lane** selected (04-interactions.md ▸ Grammar, ▸ The map).
|
|
///
|
|
/// - ←/→ move the lane selection one lane, inert at the ends — **and the trash is never reached**
|
|
/// ("with a lane selected, ←/→ and ⌥→ stop at the last real lane"), which falls out for free
|
|
/// from walking the live lane order and nothing else.
|
|
/// - ⇧←/⇧→ extend that selection from the anchor, the same range a ⇧-click would give.
|
|
/// - ↓ descends back into the lane's cards at the first card, ⌥↓ at the last; an empty lane has
|
|
/// nothing to descend into.
|
|
/// - ↑ and ⌥↑ are inert: the lane domain is the top of the hierarchy.
|
|
/// - ⌥←/⌥→ jump to the first/last lane, staying in the lane domain.
|
|
private func laneArrow(
|
|
_ direction: NavigationMath.Direction,
|
|
_ mode: ArrowMode,
|
|
from head: ItemID
|
|
) -> KeyPress.Result {
|
|
let lanes = SelectionGrammar.liveLanes(in: store.snapshot)
|
|
guard let index = lanes.firstIndex(of: head) else { return .handled }
|
|
|
|
switch (direction, mode) {
|
|
case (.left, .step), (.right, .step), (.left, .extend), (.right, .extend):
|
|
let next = index + (direction == .left ? -1 : 1)
|
|
guard lanes.indices.contains(next) else { return .handled }
|
|
if mode == .step {
|
|
replaceSelection(with: lanes[next], on: .live)
|
|
} else {
|
|
let anchor = store.transient.selectionAnchor ?? head
|
|
guard let ids = SelectionGrammar.range(
|
|
from: anchor,
|
|
to: lanes[next],
|
|
kind: .lane,
|
|
on: .live,
|
|
in: store.snapshot
|
|
) else { return .handled }
|
|
store.select(ids, liveness: .live, anchor: anchor, head: lanes[next])
|
|
}
|
|
|
|
case (.left, .jump), (.right, .jump):
|
|
guard let target = direction == .left ? lanes.first : lanes.last else { return .handled }
|
|
replaceSelection(with: target, on: .live)
|
|
|
|
case (.down, .step), (.down, .jump):
|
|
guard let lane = store.snapshot.lanes.first(where: { $0.id == head && !$0.isDeleted }) else {
|
|
return .handled
|
|
}
|
|
let cards = lane.cards.filter { !$0.isDeleted }
|
|
guard let target = mode == .jump ? cards.last : cards.first else { return .handled }
|
|
replaceSelection(with: target.id, on: .live)
|
|
|
|
case (.up, _), (.down, .extend):
|
|
// Nothing above the lane domain, and no vertical range within it.
|
|
break
|
|
}
|
|
return .handled
|
|
}
|
|
|
|
// MARK: Shared
|
|
|
|
/// A jump's and a plain step's shared landing: one item, both cursors on it.
|
|
private func replaceSelection(with id: ItemID, on side: Liveness) {
|
|
store.select([id], liveness: side, anchor: id, head: id)
|
|
}
|
|
|
|
/// The first rendered card of the first lane that has one — the scan every "first/last lane"
|
|
/// destination shares, run over the lane order forwards or reversed.
|
|
private static func firstCard(scanning lanes: some Sequence<Lane>) -> ItemID? {
|
|
for lane in lanes {
|
|
if let card = lane.cards.first(where: { !$0.isDeleted }) { return card.id }
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Resize shadow
|
|
|
|
/// The resting footprint a lane snaps back to, drawn behind the live lane during a resize.
|
|
///
|
|
/// Minimal on purpose: 03-board-ui.md's placeholder/drag vocabulary lands with the drag milestone,
|
|
/// and this is the same shape that card and lane drops will want. Kept here rather than invented
|
|
/// twice.
|
|
private struct LaneResizeShadow: View {
|
|
var body: some View {
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.fill(.quaternary.opacity(0.5))
|
|
}
|
|
}
|