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`). /// /// ### What is deliberately not here yet /// /// Selection, drag and drop, the trash quasi-lane, the toolbar, search, styling and the lane context /// menu all belong to later milestone cards. This view is the layout and the resize interaction, and /// the chrome inside `LaneView` is a placeholder those cards replace. 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? /// 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() /// 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, totalUnits: LaneLayoutMath.totalUnits(of: lanes), gap: spacing) HStack(alignment: .top, spacing: spacing) { ForEach(lanes) { lane in laneSlot(lane, standard: standard) } } .padding(spacing) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } /// 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. @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 { 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(lane: lane, columns: units) .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. .disabled(store.isReadOnly) } } /// 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: - 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)) } }