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:
2026-07-31 07:34:30 -04:00
parent 95133860e1
commit f174a524af
4 changed files with 386 additions and 142 deletions
+25 -22
View File
@@ -211,14 +211,17 @@ enum DropSlotMath {
/// Cursor proposal in three steps (DRAG-REORDER.md § The card masonry):
///
/// 1. **Column** the cursor's x-band picks interior column `c`, clamped inward at the edges.
/// 2. **Row** column `c`'s cards are logical indices `c, c + C, c + 2C, `; their vertical
/// extents feed the *same* span-capped 1D machinery the strip uses, with `draggedSpan` the
/// first dragged card's frozen height. Dead regions hold; the tail slot below the column's
/// last card is uncapped.
/// 3. **Logical index** column `c`, row `r` is position `r * C + c`, clamped to
/// `heights.count`. Every column's tail slot maps at or past the end, so "below the last
/// card of any column" is the end slot: appending, which is the honest reading, since a
/// round-robin masonry has no landing spot below one column that is not simply the end.
/// 2. **Row** column `c`'s cards are the *contiguous* logical range `[start(c), start(c + 1))`
/// (`MasonryPlacement.columnStart(_:itemCount:)`); their vertical extents feed the *same*
/// span-capped 1D machinery the strip uses, with `draggedSpan` the first dragged card's
/// frozen height. Dead regions hold; the tail slot below the column's last card is uncapped,
/// as is the region above its first.
/// 3. **Logical index** column `c`, row `r` is position `start(c) + r`, and no clamp is
/// needed: `r` never exceeds the column's card count, so the answer never leaves
/// `0...heights.count`. A column's tail maps to `start(c + 1)` the head of the next column,
/// a genuine mid-list position so "below this column" proposes landing there rather than
/// appending. Only the *last* column's tail is the end slot, which is the honest reading now
/// that the columns are read in order.
///
/// - Parameters:
/// - cursor: the pointer in the same space as `placement.origin`.
@@ -238,34 +241,34 @@ enum DropSlotMath {
) -> Int? {
let count = heights.count
guard count > 0 else { return 0 }
let columns = placement.columnCount
// The proposal's own column, where it has one. The end slot belongs to every column's tail
// (each tail maps at or past the end), so it never rules a column out.
// The proposal's own column, consulted only to settle an exact band tie. The end slot is
// the last column's tail, so it names that column rather than no column at all.
let currentColumn: Int? = {
guard let current, current >= 0, current < count else { return nil }
return placement.column(of: current)
guard let current, (0...count).contains(current) else { return nil }
return placement.column(of: current, itemCount: count)
}()
let column = columnIndex(atX: cursor.x, placement: placement, currentColumn: currentColumn)
let frames = placement.frames(heights: heights)
let positions = stride(from: column, to: count, by: columns).map { $0 }
let extents = positions.map { frames[$0].minY...frames[$0].maxY }
let start = placement.columnStart(column, itemCount: count)
let end = placement.columnStart(column + 1, itemCount: count)
let extents = (start..<end).map { frames[$0].minY...frames[$0].maxY }
// The row this column would hold the current proposal at: its own row when the proposal
// lives in this column, this column's tail when the proposal is the end slot, and nothing
// when it belongs to another column where a hold would be meaningless.
// The row this column would hold the current proposal at. `start...end` is exactly the set
// of logical positions this column's rows name its own cards' positions plus its tail
// so a proposal outside it belongs to another column, where a hold would be meaningless and
// the answer is nothing.
let currentRow: Int? = {
guard let current, current >= 0 else { return nil }
if current >= count { return positions.count }
return placement.column(of: current) == column ? placement.row(of: current) : nil
guard let current, (start...end).contains(current) else { return nil }
return current - start
}()
guard let row = slot(cursor: cursor.y, extents: extents, gap: placement.spacing,
draggedSpan: draggedHeight, current: currentRow)
else { return nil }
return min(placement.index(column: column, row: row), count)
return placement.index(column: column, row: row, itemCount: count)
}
// MARK: - Applying a proposal
+11 -13
View File
@@ -704,20 +704,18 @@ struct LaneView: View {
// holds identically under Reduce Motion: a transition that does not fire has no
// variant to choose between.
.transition(Motion.cardTransition(reduced: reduceMotion))
// **VoiceOver reads the masonry by `order`, not by column** 10-accessibility.md
// Logical order, not masonry position (decided): "within a wide lane,
// VoiceOver reads cards by `order` the interior grid columns are presentation
// only. This deliberately diverges from on-screen geometry."
// **VoiceOver reads the masonry by `order`, not by drawn position**
// 10-accessibility.md Logical order, not masonry position (decided).
//
// The divergence is real and it is why an explicit priority is needed at all:
// `MasonryLayout` assigns child `i` to column `i % columns`, so in a 3-unit lane
// the second card by `order` is drawn to the *right* of the first, not below it
// and an accessibility tree sorted by geometry (which is what a container does
// without this) would read the board column-major: 1, 4, 7, 2, 5, 8 , an order
// that exists nowhere in the model, on disk, or in the keyboard grammar.
// Priority descends with the slot index, so the highest reads first and the list
// is exactly `slots` the same sequence the masonry is handed and the same one
// `SelectionGrammar` flattens.
// The divergence narrowed when the masonry went column-major walking down
// one column now *is* consecutive `order` but it is still real, and it is
// why an explicit priority is needed at all: a geometry-sorted accessibility
// tree (which is what a container does without this) sweeps in reading order,
// left-to-right then down, which over a column-major grid interleaves the
// columns: 1, 4, 7, 2, 5, 8 , an order that exists nowhere in the model, on
// disk, or in the keyboard grammar. Priority descends with the slot index, so
// the highest reads first and the list is exactly `slots` the same sequence
// the masonry is handed and the same one `SelectionGrammar` flattens.
//
// The drag shadows are inert here: `DragShadow` hides itself from the tree, and
// a slot that is not an element consumes no priority.
+98 -29
View File
@@ -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