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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user