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