Files
lanework/Kanban/UI/Board/MasonryLayout.swift
T
rzen f174a524af Masonry goes column-major — cards read top-down, then across
Replaces the pathfinder-inherited round-robin deal (child i -> column i % C)
with contiguous column segments: base = n/C, the first n%C columns take one
more, and logical order runs down each column before crossing to the next.
Only the geometric mapping changes -- ranks, selection flatten, and VoiceOver
order are untouched, and MasonryPlacement stays the single placement function
both the Layout and the drop model replay.

Why: an insertion under round-robin shifted every later card across columns;
under the column-major deal later cards slide within their column and at most
one card crosses each boundary, so the drag reflow is far calmer. Drop-slot
math gets simpler too -- a column's cards are one contiguous range, a
non-final column's tail is now a genuine mid-list position, and only the last
column's tail means append.

DropSlotMathTests recomputed and extended (46 -> 50): the uneven-fill deal,
boundary positions, the shared tail/head boundary index, and a placement/
drop-model shadow-agreement check. DRAG-REORDER.md and DESIGN/10 amendments
are listed for ratification, deliberately not edited here.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-31 07:34:30 -04:00

266 lines
14 KiB
Swift

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..<extra · (base + 1)`; past that every column holds `base`. `base` can only be zero when
/// every child fits in the taller columns, so the second branch never divides by it.
func column(of index: Int, itemCount: Int) -> 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..<columnCount {
let x = columnX(column)
var top = origin.y
for index in columnStart(column, itemCount: heights.count)
..< columnStart(column + 1, itemCount: heights.count) {
frames.append(CGRect(x: x, y: top, width: columnWidth, height: heights[index]))
top += heights[index] + spacing
}
}
return frames
}
/// The grid's total height — the tallest column's stack, which is what `sizeThatFits`
/// reports.
func height(heights: [CGFloat]) -> CGFloat {
var tallest: CGFloat = 0
for column in 0..<columnCount {
var total: CGFloat = 0
for index in columnStart(column, itemCount: heights.count)
..< columnStart(column + 1, itemCount: heights.count) {
total += heights[index] + (total > 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))
}
}
}