import CoreGraphics import SwiftUI /// Where a masonry puts its children, as pure arithmetic — no views, no `Layout`, no measurement /// (`MasonryPlacementTests`). /// /// `MasonryLayout` below *is* this function plus SwiftUI's measurement cache, and the drag model /// reconstructs a lane's resting card grid by replaying it over the frozen heights /// (DRAG-REORDER.md § The card masonry). Extracting it is what makes those two the same /// arithmetic rather than two implementations that agree until one of them is edited — the /// analytic-resting-layout rule (03-board-ui.md § Motion, "motion never feeds back into logic") /// only pays off if what is computed analytically is what is actually drawn. /// /// **The assignment is column-major, and that is the whole model**: the children are dealt out in /// contiguous runs, one run per column, filling each column top to bottom before starting the next. /// With `n` children and `C` columns the runs are as even as they can be — `base = n / C`, and the /// first `extra = n % C` columns take one more each — so column `c` holds exactly the logical /// indices `[start(c), start(c + 1))`, where `start` is the prefix sum of those sizes /// (`columnStart(_:itemCount:)`). /// /// Row `r` of column `c` is therefore logical index `start(c) + r`, and the inverse is a lookup of /// which run `i` falls in — which is how a cursor position becomes an insertion index /// (`DropSlotMath.cardSlot`). Two consequences worth having in mind: /// /// - **Every mapping is a function of the child count**, not of the index alone. `column(of:)`, /// `row(of:)` and `index(column:row:)` all take `itemCount:` for that reason; a grid that gains or /// loses a child re-deals, and asking about a stale count gives a stale answer. /// - **A column's tail is a real mid-list position.** Column `c`'s tail row is logical index /// `start(c + 1)`, which is the head of column `c + 1` — only the *last* column's tail is the end /// of the list. That is what lets a drag propose "below this column" without meaning "append". struct MasonryPlacement: Equatable, Sendable { /// Number of interior columns (the lane's width units); clamped to ≥ 1 at every use. let columnCount: Int /// One column's width — the standard card width, since every card is one column wide. let columnWidth: CGFloat /// Spacing between columns and between stacked cards within a column. let spacing: CGFloat /// The grid's top-leading corner, in whatever space the caller is working in. let origin: CGPoint init(columnCount: Int, columnWidth: CGFloat, spacing: CGFloat, origin: CGPoint = .zero) { self.columnCount = max(1, columnCount) self.columnWidth = columnWidth self.spacing = spacing self.origin = origin } /// The column width `columnCount` columns and their interior spacings divide `totalWidth` /// into — `MasonryLayout`'s own expression, floored at zero so a lane narrower than its /// spacings never proposes a negative width. static func columnWidth(totalWidth: CGFloat, columnCount: Int, spacing: CGFloat) -> CGFloat { let count = CGFloat(max(1, columnCount)) return max(0, (totalWidth - spacing * (count - 1)) / count) } /// The logical index interior column `column` begins at, when `itemCount` children are dealt out /// column-major — the prefix sum `c · base + min(c, extra)`. /// /// Total over `0...columnCount`, and deliberately so: `columnStart(c + 1, itemCount:)` is column /// `c`'s **exclusive end**, which is both the position past its last child and the logical index /// its tail slot proposes. At `c = columnCount` it is `itemCount` itself — the end of the list. func columnStart(_ column: Int, itemCount: Int) -> Int { let column = min(max(0, column), columnCount) let base = itemCount / columnCount let extra = itemCount % columnCount return column * base + min(column, extra) } /// How many children interior column `column` holds — `base + 1` for the first `extra` columns, /// `base` for the rest, expressed as the one difference that makes it impossible for the sizes /// and the starts to disagree. func childCount(inColumn column: Int, itemCount: Int) -> Int { columnStart(column + 1, itemCount: itemCount) - columnStart(column, itemCount: itemCount) } /// The interior column child `index` is assigned to, in a grid of `itemCount` children — which /// contiguous run `index` falls in, by division rather than by a scan. /// /// The first `extra` columns hold `base + 1` children each and so cover indices /// `0.. Int { guard itemCount > 0 else { return 0 } let index = min(max(0, index), itemCount - 1) let base = itemCount / columnCount let extra = itemCount % columnCount let taller = extra * (base + 1) if index < taller { return index / (base + 1) } return extra + (index - taller) / base } /// The row within its column child `index` stacks at, in a grid of `itemCount` children. func row(of index: Int, itemCount: Int) -> Int { guard itemCount > 0 else { return 0 } let index = min(max(0, index), itemCount - 1) return index - columnStart(column(of: index, itemCount: itemCount), itemCount: itemCount) } /// The logical position that row `row` of column `column` holds in a grid of `itemCount` /// children — `column(of:itemCount:)`/`row(of:itemCount:)` inverted. /// /// Unclamped in `row`, and it needs no clamp: a caller asking for a column's tail row (`row` = /// `childCount(inColumn:itemCount:)`) gets `columnStart(column + 1, itemCount:)`, which is a /// position *inside* the list for every column but the last, and exactly `itemCount` for that /// one. Column-major is what makes "below this column" a landing spot rather than an append. func index(column: Int, row: Int, itemCount: Int) -> Int { columnStart(column, itemCount: itemCount) + row } /// The leading x of interior column `column`. func columnX(_ column: Int) -> CGFloat { origin.x + CGFloat(column) * (columnWidth + spacing) } /// Every child's frame, in child order, for children of the given heights. /// /// Walking the columns in order walks the children in order too — that is precisely what /// column-major means — so the frames come out in child order with no second pass. func frames(heights: [CGFloat]) -> [CGRect] { var frames: [CGRect] = [] frames.reserveCapacity(heights.count) for column in 0.. CGFloat { var tallest: CGFloat = 0 for column in 0.. 0 ? spacing : 0) } tallest = max(tallest, total) } return tallest } } /// Masonry layout for a lane's interior card columns (03-board-ui.md § Layout — full visibility: /// "a wide lane flows them into as many interior masonry columns as it has units"; § Lane: "masonry /// grid when wide — settled, the pathfinder's masonry works"). /// /// Children are dealt **column-major** into `columns` equal-width vertical columns — read top to /// bottom down one column, then across to the next — with the runs as even as they divide (the /// first `count % columns` columns take one extra child each; `MasonryPlacement`). Each column /// stacks its children top-aligned and independently: there is **no row alignment across columns**. /// With uniform card heights this renders exactly like a newspaper's columns, but when one card /// grows taller than its neighbours (a longer title wrapping across more lines, say) it only pushes /// the cards below it in its *own* column; the neighbouring columns do not move. /// /// A `Layout` rather than an `HStack` of per-column `VStack`s so the caller keeps a single /// `ForEach` — reflowing cards across columns preserves view identity and animates as positional /// moves, not as remove/insert transitions. That is what lets the interior reflow *during* a resize /// drag read as cards sliding rather than blinking. /// /// **Vocabulary note.** In Lanework a "lane" is the kanban column; this layout's own interior /// tracks are "columns". The pathfinder called them lanes, which is why the ported reasoning below /// reads the way it does. struct MasonryLayout: Layout { /// Number of interior columns (the lane's width units); clamped to ≥ 1. var columns: Int /// Spacing between columns and between stacked cards within a column. var spacing: CGFloat private var columnCount: Int { max(1, columns) } private func columnWidth(for totalWidth: CGFloat) -> CGFloat { MasonryPlacement.columnWidth(totalWidth: totalWidth, columnCount: columnCount, spacing: spacing) } /// The placement arithmetic for a grid of `width` points at `origin` — the one expression both /// this layout and the drag model's resting grid go through (`MasonryPlacement`). private func placement(width: CGFloat, origin: CGPoint) -> MasonryPlacement { MasonryPlacement(columnCount: columnCount, columnWidth: columnWidth(for: width), spacing: spacing, origin: origin) } // MARK: - Measurement cache // // A lane with several hundred cards is measured a LOT: SwiftUI probes a layout's `sizeThatFits` // more than once per pass (different proposals), and `placeSubviews` needs every height again // right after. Unmemoized that is `subviews.count` full subtree measurements per call. // // The cache holds one height per subview, keyed by the column width they were measured at: // column width is the only thing this layout ever proposes (height is always `nil`, so a card's // height is a pure function of its width and its content). A different column width — a window // resize, a width change — discards the whole table, which is correct and cheap: it is exactly // the case where every height really did change. // // Staleness is handled by SwiftUI itself: `updateCache` runs whenever the layout's subviews // change, which is the only way a card's measured height can change at a fixed column width // (its content changed → its view tree was rebuilt → the layout's content is new). Clearing // there means the cache never outlives the content it measured. struct Cache { var columnWidth: CGFloat = .nan var heights: [CGFloat] = [] } func makeCache(subviews: Subviews) -> Cache { Cache() } func updateCache(_ cache: inout Cache, subviews: Subviews) { cache = Cache() } /// `subviews[index]`'s height at `column` width, measured once per (content, column width) /// generation and reused for every later probe and for the placement pass. private func height(of subviews: Subviews, at index: Int, column: CGFloat, cache: inout Cache) -> CGFloat { if cache.columnWidth != column || cache.heights.count != subviews.count { cache.columnWidth = column cache.heights = [CGFloat](repeating: .nan, count: subviews.count) } if !cache.heights[index].isNaN { return cache.heights[index] } let height = subviews[index].sizeThatFits(ProposedViewSize(width: column, height: nil)).height cache.heights[index] = height return height } /// Every subview's height at `column` width, in subview order — the input `MasonryPlacement` /// takes, gathered through the cache above so both passes measure once between them. private func measuredHeights(of subviews: Subviews, at column: CGFloat, cache: inout Cache) -> [CGFloat] { var heights: [CGFloat] = [] heights.reserveCapacity(subviews.count) for index in subviews.indices { heights.append(height(of: subviews, at: index, column: column, cache: &cache)) } return heights } func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize { let width = proposal.width ?? 0 let placement = placement(width: width, origin: .zero) let heights = measuredHeights(of: subviews, at: placement.columnWidth, cache: &cache) return CGSize(width: width, height: placement.height(heights: heights)) } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) { let placement = placement(width: bounds.width, origin: bounds.origin) let heights = measuredHeights(of: subviews, at: placement.columnWidth, cache: &cache) for (index, frame) in placement.frames(heights: heights).enumerated() { subviews[index].place(at: frame.origin, proposal: ProposedViewSize(width: frame.width, height: frame.height)) } } }