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 keyboard's narrow slice** — Return's create/rename dispatch and Escape's step outward. /// /// - **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. The **selection grammar** here is /// likewise minimal — a click replaces the selection, and that is all: ⌘-click toggling, ⇧-click /// ranges, the rubber band and the cards-XOR-lanes homogeneity rule are m5's selection-model card. 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() /// 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" /// 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 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 (04-interactions.md ▸ Drag and drop). The proposal is recomputed // here on every render, so a foreign reload mid-drag simply moves the zones (rule 1 of // that section's re-grounding trio). let shown = shownLanes(lanes, standard: standard) HStack(alignment: .top, spacing: spacing) { ForEach(Array(shown.enumerated()), id: \.element.id) { position, lane in laneSlot(lane, at: position, among: shown, standard: standard) } 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 ) .frame(width: LaneLayoutMath.slotWidth(units: 1, standard: standard, gap: spacing)) .frame(maxHeight: .infinity, alignment: .top) } } .padding(spacing) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) // 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. .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(.return) { handleReturn() } .onKeyPress(.escape) { handleEscape() } .onKeyPress(keys: [.delete], phases: .down) { handleDelete($0) } } // 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), 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 /// The order the strip shows: the snapshot's at rest, the drag's proposal while one is in /// flight. A drag whose lane has vanished from the snapshot shows the plain order and proposes /// nothing — its release then cancels (04 ▸ Drag and drop, "an emptied drag cancels itself"). private func shownLanes(_ lanes: [Lane], standard: CGFloat) -> [Lane] { guard let (from, to) = proposal(among: lanes, standard: standard) else { return lanes } return LaneReorderMath.reordered(lanes, from: from, to: to) } /// 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. 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. /// /// The full keyboard map — arrows, ⌥-jumps, the ⌥↑ escalation, ⌫, the ⌥⌘ moves — is **m5's /// keyboard-grammar card**. This is the creation/rename pair and nothing else. /// /// 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() -> KeyPress.Result { 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: - 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)) } }