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
This commit is contained in:
@@ -11,10 +11,23 @@ import SwiftUI
|
||||
/// 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 round-robin, and that is the whole model**: child `i` lands in column
|
||||
/// `i % columnCount` at the bottom of that column's independent stack. Row `r` of column `c` is
|
||||
/// therefore logical index `r * columnCount + c`, and the inverse is division — which is how a
|
||||
/// cursor position becomes an insertion index (`DropSlotMath.cardSlot`).
|
||||
/// **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.
|
||||
@@ -44,16 +57,59 @@ struct MasonryPlacement: Equatable, Sendable {
|
||||
return max(0, (totalWidth - spacing * (count - 1)) / count)
|
||||
}
|
||||
|
||||
/// The interior column child `index` is assigned to.
|
||||
func column(of index: Int) -> Int { index % columnCount }
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// The row within its column child `index` stacks at.
|
||||
func row(of index: Int) -> Int { index / columnCount }
|
||||
/// 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 logical position that row `row` of column `column` holds — `column(of:)`/`row(of:)`
|
||||
/// inverted. Unclamped: a caller asking for a column's tail row gets a position at or past
|
||||
/// the end, which is exactly what the end slot means.
|
||||
func index(column: Int, row: Int) -> Int { row * columnCount + column }
|
||||
/// 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 {
|
||||
@@ -61,25 +117,37 @@ struct MasonryPlacement: Equatable, Sendable {
|
||||
}
|
||||
|
||||
/// 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 tops = [CGFloat](repeating: origin.y, count: columnCount)
|
||||
return heights.enumerated().map { index, height in
|
||||
let target = column(of: index)
|
||||
let frame = CGRect(x: columnX(target), y: tops[target], width: columnWidth, height: height)
|
||||
tops[target] += height + spacing
|
||||
return frame
|
||||
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 totals = [CGFloat](repeating: 0, count: columnCount)
|
||||
for (index, height) in heights.enumerated() {
|
||||
let target = column(of: index)
|
||||
totals[target] += height + (totals[target] > 0 ? spacing : 0)
|
||||
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 totals.max() ?? 0
|
||||
return tallest
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,12 +155,13 @@ struct MasonryPlacement: Equatable, Sendable {
|
||||
/// "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 assigned round-robin to `columns` equal-width vertical columns (child `i` → column
|
||||
/// `i % columns`), and 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
|
||||
/// row-major grid, 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.
|
||||
/// 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
|
||||
|
||||
Reference in New Issue
Block a user