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
+252 -78
View File
@@ -275,47 +275,93 @@ struct LaneSlotTests {
struct MasonryPlacementTests {
private let placement = MasonryPlacement(columnCount: 2, columnWidth: 100, spacing: 8)
@Test("Children are assigned round-robin and each column stacks independently")
func roundRobinStacking() {
@Test("Children are dealt column-major and each column stacks independently")
func columnMajorStacking() {
// Five cards over two columns: `base = 2`, `extra = 1`, so column 0 takes three and column
// 1 takes two, and the logical indices run contiguously down each.
let frames = placement.frames(heights: [40, 60, 30, 20, 50])
#expect(frames == [
CGRect(x: 0, y: 0, width: 100, height: 40), // column 0, row 0
CGRect(x: 108, y: 0, width: 100, height: 60), // column 1, row 0
CGRect(x: 0, y: 48, width: 100, height: 30), // column 0, row 1 under card 0 only
CGRect(x: 108, y: 68, width: 100, height: 20), // column 1, row 1 under card 1 only
CGRect(x: 0, y: 86, width: 100, height: 50),
CGRect(x: 0, y: 48, width: 100, height: 60), // column 0, row 1 under card 0
CGRect(x: 0, y: 116, width: 100, height: 30), // column 0, row 2 it holds the extra
CGRect(x: 108, y: 0, width: 100, height: 20), // column 1, row 0
CGRect(x: 108, y: 28, width: 100, height: 50), // column 1, row 1 under card 3 only
])
}
@Test("The columns divide as evenly as they can, and the starts are the prefix sums")
func theDeal() {
// `base = n / C`, `extra = n % C`: the first `extra` columns take one more each.
func sizes(_ count: Int, columns: Int) -> [Int] {
let placement = MasonryPlacement(columnCount: columns, columnWidth: 100, spacing: 8)
return (0..<columns).map { placement.childCount(inColumn: $0, itemCount: count) }
}
func starts(_ count: Int, columns: Int) -> [Int] {
let placement = MasonryPlacement(columnCount: columns, columnWidth: 100, spacing: 8)
return (0...columns).map { placement.columnStart($0, itemCount: count) }
}
#expect(sizes(6, columns: 3) == [2, 2, 2]) // an even fill
#expect(starts(6, columns: 3) == [0, 2, 4, 6])
#expect(sizes(7, columns: 3) == [3, 2, 2]) // one column takes the remainder
#expect(starts(7, columns: 3) == [0, 3, 5, 7])
#expect(sizes(8, columns: 3) == [3, 3, 2]) // two do
#expect(starts(8, columns: 3) == [0, 3, 6, 8])
#expect(sizes(2, columns: 3) == [1, 1, 0]) // fewer cards than columns
#expect(starts(2, columns: 3) == [0, 1, 2, 2])
#expect(sizes(0, columns: 3) == [0, 0, 0])
#expect(starts(0, columns: 3) == [0, 0, 0, 0])
// The last start is always the count a column's exclusive end is a real position, and the
// last column's is the end of the list.
for count in 0...12 {
for columns in 1...4 {
#expect(starts(count, columns: columns).last == count, "\(count) over \(columns)")
#expect(sizes(count, columns: columns).reduce(0, +) == count)
#expect(sizes(count, columns: columns).max()!
- sizes(count, columns: columns).min()! <= 1,
"the columns are never more than one card apart")
}
}
}
@Test("The placement matches an independent reading of the documented rule")
func differentialAgainstTheStatedRule() {
// A second implementation of the rule as 03-board-ui.md states it "child `i` column
// `i % columns`, each column stacks top-aligned and independently" written from the
// words rather than from the code. `MasonryLayout` places subviews through
// `MasonryPlacement`, so agreeing here is agreeing with what is drawn.
// A second implementation of the rule as stated the children dealt out in contiguous runs,
// one per column, the first `n % C` columns taking one extra each, each column stacking
// top-aligned and independently written from the words rather than from the code.
// `MasonryLayout` places subviews through `MasonryPlacement`, so agreeing here is agreeing
// with what is drawn.
func naive(_ heights: [CGFloat], columns: Int, width: CGFloat, spacing: CGFloat,
origin: CGPoint) -> [CGRect] {
var stacks = [[CGFloat]](repeating: [], count: columns)
let base = heights.count / columns
let extra = heights.count % columns
var frames: [CGRect] = []
for (index, height) in heights.enumerated() {
let column = index % columns
let stacked = stacks[column].reduce(0) { $0 + $1 + spacing }
frames.append(CGRect(x: origin.x + CGFloat(column) * (width + spacing),
y: origin.y + stacked,
width: width, height: height))
stacks[column].append(height)
var next = 0
for column in 0..<columns {
var stacked: [CGFloat] = []
for _ in 0..<(base + (column < extra ? 1 : 0)) {
frames.append(CGRect(x: origin.x + CGFloat(column) * (width + spacing),
y: origin.y + stacked.reduce(0) { $0 + $1 + spacing },
width: width, height: heights[next]))
stacked.append(heights[next])
next += 1
}
}
return frames
}
let heights: [CGFloat] = [40, 60, 30, 20, 50, 55, 12]
for columns in 1...4 {
let origin = CGPoint(x: 17, y: 23)
let placement = MasonryPlacement(columnCount: columns, columnWidth: 100,
spacing: 8, origin: origin)
#expect(placement.frames(heights: heights)
== naive(heights, columns: columns, width: 100, spacing: 8, origin: origin),
"\(columns) interior columns")
for count in 0...heights.count {
for columns in 1...4 {
let origin = CGPoint(x: 17, y: 23)
let placement = MasonryPlacement(columnCount: columns, columnWidth: 100,
spacing: 8, origin: origin)
let slice = Array(heights.prefix(count))
#expect(placement.frames(heights: slice)
== naive(slice, columns: columns, width: 100, spacing: 8, origin: origin),
"\(count) cards over \(columns) interior columns")
}
}
}
@@ -323,21 +369,42 @@ struct MasonryPlacementTests {
func heightIsTheTallestColumn() {
let heights: [CGFloat] = [40, 60, 30, 20, 50]
let frames = placement.frames(heights: heights)
// Column 0 stacks 40 + 8 + 30 + 8 + 50 = 136; column 1 stacks 60 + 8 + 20 = 88.
#expect(placement.height(heights: heights) == 136)
// Column 0 stacks 40 + 8 + 60 + 8 + 30 = 146; column 1 stacks 20 + 8 + 50 = 78.
#expect(placement.height(heights: heights) == 146)
#expect(placement.height(heights: heights) == frames.map(\.maxY).max())
#expect(placement.height(heights: []) == 0)
#expect(placement.frames(heights: []).isEmpty)
}
@Test("Column and row invert to the logical index")
@Test("Column and row invert to the logical index, at every fill")
func columnRowInversion() {
for index in 0..<9 {
#expect(placement.index(column: placement.column(of: index),
row: placement.row(of: index)) == index)
// The mapping is a function of the child count, not of the index alone so the round trip
// has to hold at every count, not just the one the fixture happens to render.
for columns in 1...4 {
let placement = MasonryPlacement(columnCount: columns, columnWidth: 100, spacing: 8)
for count in 1...12 {
for index in 0..<count {
let column = placement.column(of: index, itemCount: count)
let row = placement.row(of: index, itemCount: count)
#expect(placement.index(column: column, row: row, itemCount: count) == index,
"index \(index) of \(count) over \(columns)")
#expect((0..<columns).contains(column))
#expect(row < placement.childCount(inColumn: column, itemCount: count))
}
}
}
#expect(placement.column(of: 3) == 1)
#expect(placement.row(of: 3) == 1)
// The fixture's own reading: five cards over two columns puts card 3 at the *top* of column
// 1, where round-robin used to put it in row 1.
#expect(placement.column(of: 3, itemCount: 5) == 1)
#expect(placement.row(of: 3, itemCount: 5) == 0)
#expect(placement.column(of: 2, itemCount: 5) == 0)
#expect(placement.row(of: 2, itemCount: 5) == 2)
// A column's tail row names the next column's head the whole of why a tail is a landing
// spot rather than an append.
#expect(placement.index(column: 0, row: 3, itemCount: 5) == 3)
#expect(placement.index(column: 1, row: 2, itemCount: 5) == 5)
}
@Test("Column width divides the lane, and a degenerate column count clamps to one")
@@ -352,10 +419,11 @@ struct MasonryPlacementTests {
// MARK: - The masonry's insertion index
/// A 2-wide lane of five cards, 100pt columns and 8pt spacing:
/// column 0 (x 0100): card 0 [0, 40] · card 2 [48, 78] · card 4 [86, 136]
/// column 1 (x 108208): card 1 [0, 60] · card 3 [68, 88]
/// Column bands meet at 104. Column 0's zone boundaries are 44, 82, 140; column 1's are 64, 92.
/// A 2-wide lane of five cards, 100pt columns and 8pt spacing. Column-major with `base = 2` and
/// `extra = 1`, so column 0 holds three cards and column 1 holds two:
/// column 0 (x 0100): card 0 [0, 40] · card 1 [48, 108] · card 2 [116, 146]
/// column 1 (x 108208): card 3 [0, 20] · card 4 [28, 78]
/// Column bands meet at 104. Column 0's zone boundaries are 44, 112, 150; column 1's are 24, 82.
@Suite("DropSlotMath ▸ the card masonry")
struct CardSlotTests {
private let placement = MasonryPlacement(columnCount: 2, columnWidth: 100, spacing: 8)
@@ -369,7 +437,7 @@ struct CardSlotTests {
@Test("A cursor over a card claims that card's logical position")
func cursorOverACardClaimsItsLogicalPosition() {
let probes: [(CGFloat, CGFloat, Int)] = [
(20, 20, 0), (150, 20, 1), (20, 60, 2), (150, 75, 3), (20, 100, 4),
(20, 20, 0), (20, 60, 1), (20, 130, 2), (150, 10, 3), (150, 40, 4),
]
for (x, y, expected) in probes {
for current in [nil, 0, 1, 2, 3, 4, 5] {
@@ -379,43 +447,51 @@ struct CardSlotTests {
}
}
@Test("Column, then row, then r · C + c — the round-robin inverse")
@Test("Column, then row, then start(c) + r — the column-major inverse")
func columnAndRowComposeTheIndex() {
// Column 1's second row is logical position 3, not "the fourth thing the cursor passed":
// Column 1's second row is logical position 4, not "the fifth thing the cursor passed":
// the index is the lane's card order, which is what the store writes and what VoiceOver
// traverses (10-accessibility.md's logical-order rule).
#expect(slot(150, 75, current: nil) == 3)
#expect(placement.column(of: 3) == 1)
#expect(placement.row(of: 3) == 1)
#expect(slot(150, 40, current: nil) == 4)
#expect(placement.column(of: 4, itemCount: heights.count) == 1)
#expect(placement.row(of: 4, itemCount: heights.count) == 1)
#expect(placement.columnStart(1, itemCount: heights.count) == 3)
}
@Test("Below any column's last card is the end slot")
func belowAColumnIsTheEndSlot() {
#expect(slot(20, 200, current: nil) == 5, "below column 0 — clamped past the end")
#expect(slot(150, 200, current: nil) == 5, "below column 1 — exactly the end")
#expect(slot(20, 200, current: 1) == 5)
@Test("A column's tail is that column's end — a real position, not the end of the lane")
func aColumnsTailIsItsOwnEnd() {
// The column-major model's substantive gain over round-robin: below column 0 is a landing
// spot *between* the columns, not an append. It is column 0's exclusive end, which is the
// same logical position as the head of column 1.
#expect(slot(20, 200, current: nil) == 3, "below column 0 — column 0's end, mid-list")
#expect(slot(20, 200, current: nil) == placement.columnStart(1, itemCount: heights.count))
#expect(slot(20, 200, current: 1) == 3)
// Only the last column's tail is the end of the lane.
#expect(slot(150, 200, current: nil) == 5, "below column 1 — the end slot")
#expect(slot(150, 200, current: nil) == heights.count)
}
@Test("Above and beside the grid clamp inward to the nearest column")
func clampingAtTheEdges() {
#expect(slot(20, -40, current: nil) == 0, "the lane header targets the first row")
#expect(slot(150, -40, current: nil) == 1)
#expect(slot(150, -40, current: nil) == 3, "column 1's first row is logical position 3")
#expect(slot(-60, 20, current: nil) == 0, "the lane's leading padding is still column 0")
#expect(slot(400, 20, current: nil) == 1, "and its trailing padding column 1")
#expect(slot(400, 20, current: nil) == 3, "and its trailing padding column 1")
}
@Test("A dead region below a tall card holds the proposal")
func deadRegionHolds() {
// Dragging a 10pt card: slot 4's trigger runs from 82 for 10 + 8 100, so (100, 140) is
// the far side of card 4's zone and changes nothing.
#expect(slot(20, 95, current: 0, dragged: 10) == 4, "inside the footprint, the slot triggers")
#expect(slot(20, 120, current: 0, dragged: 10) == nil)
#expect(slot(20, 120, current: 2, dragged: 10) == nil)
// Dragging a 10pt card: card 1 is 60pt tall, so slot 1's trigger runs from 44 for 10 + 8
// 62, and (62, 112) is the far side of card 1's zone and changes nothing.
#expect(slot(20, 55, current: 0, dragged: 10) == 1, "inside the footprint, the slot triggers")
#expect(slot(20, 90, current: 0, dragged: 10) == nil)
#expect(slot(20, 90, current: 2, dragged: 10) == nil)
// With nothing to hold, the containing zone answers a drag in flight has a landing spot.
#expect(slot(20, 120, current: nil, dragged: 10) == 4)
#expect(slot(20, 90, current: nil, dragged: 10) == 1)
var current = 0
for _ in 0..<10 { current = slot(20, 120, current: current, dragged: 10) ?? current }
for _ in 0..<10 { current = slot(20, 90, current: current, dragged: 10) ?? current }
#expect(current == 0)
}
@@ -424,7 +500,7 @@ struct CardSlotTests {
// y = 44 is the boundary between column 0's slots 0 and 1. A 60pt dragged card reaches
// past it from either side, so the cap does not decide and the tie rule does.
#expect(slot(20, 44, current: 0, dragged: 60) == 0)
#expect(slot(20, 44, current: 2, dragged: 60) == 2, "slot 2 is column 0's row 1")
#expect(slot(20, 44, current: 1, dragged: 60) == 1, "slot 1 is column 0's row 1")
var index = 0
for _ in 0..<10 { index = slot(20, 44, current: index, dragged: 60) ?? index }
#expect(index == 0, "the boundary pixel is a fixed point")
@@ -432,16 +508,112 @@ struct CardSlotTests {
@Test("Re-evaluating a resting hover is a fixed point — own-slot pickup never reflows")
func ownSlotPickupIsANoOp() {
var index = 2
var index = 1
for _ in 0..<10 { index = slot(20, 60, current: index) ?? index }
#expect(index == 2)
#expect(index == 1)
}
@Test("A proposal in another column never holds this one")
func aProposalInAnotherColumnDoesNotHold() {
// Slot 1 lives in column 1; a cursor deep in column 0's dead region cannot "hold" it,
// Slot 4 lives in column 1 alone; a cursor deep in column 0's dead region cannot "hold" it,
// because holding a proposal the cursor is nowhere near would strand the shadow.
#expect(slot(20, 120, current: 1, dragged: 10) == 4)
#expect(slot(20, 90, current: 4, dragged: 10) == 1)
}
@Test("The index where two columns meet holds from either side — it is one position")
func theSharedBoundaryIndexHoldsFromEitherColumn() {
// Index 3 is column 0's tail *and* column 1's head. Both readings name the same logical
// position, so a dead region in either column legitimately holds it and the shadow stays
// exactly where it is drawn rather than jumping between two names for one spot.
#expect(slot(20, 90, current: 3, dragged: 10) == nil, "held from column 0's dead region")
#expect(slot(150, 60, current: 3, dragged: 10) == nil, "and from column 1's")
}
/// A 3-wide lane of seven uniform 40pt cards `base = 2`, `extra = 1`, so the deal is 3 / 2 / 2
/// and the columns hold indices [0, 3), [3, 5), [5, 7):
/// column 0 (x 0100): card 0 [0, 40] · card 1 [48, 88] · card 2 [96, 136]
/// column 1 (x 108208): card 3 [0, 40] · card 4 [48, 88]
/// column 2 (x 216316): card 5 [0, 40] · card 6 [48, 88]
/// Column bands meet at 104 and 212.
@Test("An uneven fill puts every boundary position where the prefix sums say")
func unevenFillBoundaries() {
let wide = MasonryPlacement(columnCount: 3, columnWidth: 100, spacing: 8)
let heights = [CGFloat](repeating: 40, count: 7)
func slot(_ x: CGFloat, _ y: CGFloat) -> Int? {
DropSlotMath.cardSlot(cursor: CGPoint(x: x, y: y), placement: wide,
heights: heights, draggedHeight: 40, current: nil)
}
#expect((0...3).map { wide.columnStart($0, itemCount: 7) } == [0, 3, 5, 7])
// Column 0 the one that took the extra card.
#expect(slot(50, 20) == 0)
#expect(slot(50, 60) == 1)
#expect(slot(50, 110) == 2)
#expect(slot(50, 300) == 3, "below column 0 is column 1's head, not the end")
// Column 1.
#expect(slot(150, 20) == 3)
#expect(slot(150, 60) == 4)
#expect(slot(150, 300) == 5, "below column 1 is column 2's head")
// Column 2 the only column whose tail is the end of the lane.
#expect(slot(250, 20) == 5)
#expect(slot(250, 60) == 6)
#expect(slot(250, 300) == 7)
#expect(slot(250, 300) == heights.count)
}
/// The layout and the drop model agree **"the drop always lands where the shadows show"**
/// (DRAG-REORDER.md § Single-target dispatch). `cardSlot` proposes a logical index; the lane
/// then renders its cards with one shadow spliced in there and hands the whole arrangement to
/// `MasonryPlacement.frames` the very function `MasonryLayout` places subviews with. So the
/// claim to pin is that reading the shadow's frame back out of *that* arrangement finds it at
/// the (column, row) the proposal's index names once the grid has re-dealt.
@Test("The shadow is drawn at the position the proposal named")
func theShadowLandsWhereProposed() {
let dragged: CGFloat = 30
let probes: [(CGFloat, CGFloat)] = [
(20, 20), (20, 60), (20, 130), (150, 10), (150, 40), (150, 200), (20, 200),
]
for (x, y) in probes {
guard let index = slot(x, y, current: nil, dragged: dragged) else {
Issue.record("(\(x), \(y)) proposed nothing")
continue
}
// What the lane renders: the resting cards with one shadow at the proposal.
var arrangement = heights
arrangement.insert(dragged, at: index)
let frames = placement.frames(heights: arrangement)
let count = arrangement.count
let column = placement.column(of: index, itemCount: count)
let start = placement.columnStart(column, itemCount: count)
#expect(frames[index].minX == placement.columnX(column),
"(\(x), \(y)) → \(index): the shadow's column")
#expect(frames[index].minY == arrangement[start..<index].reduce(0) { $0 + $1 + 8 },
"(\(x), \(y)) → \(index): the shadow stacks under its column's cards above it")
#expect(frames[index].height == dragged)
#expect(placement.row(of: index, itemCount: count) == index - start)
}
// The substantive half: for a proposal inside a column, the shadow is drawn **under the
// cursor** the trigger region is the run's future footprint, and this is that footprint.
for (x, y) in probes.prefix(5) {
let index = slot(x, y, current: nil, dragged: dragged)!
var arrangement = heights
arrangement.insert(dragged, at: index)
let frame = placement.frames(heights: arrangement)[index]
#expect(frame.minX <= x && x <= frame.maxX, "(\(x), \(y)) is inside the shadow")
#expect(frame.minY <= y && y <= frame.maxY, "(\(x), \(y)) is inside the shadow")
}
// A tail proposal is the exception, and it is honest rather than wrong: "below column 0"
// resolves to index 3, which in the re-dealt six-card grid is the *head of column 1* the
// spot the card will genuinely occupy after the drop. The shadow shows the landing, not the
// cursor.
var arrangement = heights
arrangement.insert(dragged, at: 3)
#expect(placement.column(of: 3, itemCount: arrangement.count) == 1)
#expect(placement.frames(heights: arrangement)[3] == CGRect(x: 108, y: 0, width: 100, height: 30))
}
@Test("A one-column lane behaves like a plain vertical list")
@@ -474,9 +646,10 @@ struct CardSlotTests {
/// drop, settled 2026-07-28), pinned on the same fixture the card zones are pinned on, because that
/// is the claim: a file drop resolves through *the same card-grid zones an ordinary card drag uses*.
///
/// The lane, exactly as `CardSlotTests` reads it 2 columns, 100pt wide, 8pt spacing, origin (0, 0):
/// column 0 (x 0100): card 0 [0, 40] · card 2 [48, 78] · card 4 [86, 136]
/// column 1 (x 108208): card 1 [0, 60] · card 3 [68, 88]
/// The lane, exactly as `CardSlotTests` reads it 2 columns, 100pt wide, 8pt spacing, origin (0, 0),
/// dealt column-major three cards to column 0 and two to column 1:
/// column 0 (x 0100): card 0 [0, 40] · card 1 [48, 108] · card 2 [116, 146]
/// column 1 (x 108208): card 3 [0, 20] · card 4 [28, 78]
/// Column bands meet at 104. The incoming cards have no measured height, so the zones are capped at
/// the nominal one `LaneDropRegistry.nominalCardHeight`, the same stand-in a cross-board arrival
/// gets.
@@ -501,13 +674,13 @@ struct FileDropZoneTests {
/// Every probe below that is **not** over a card, so the create branch is the one answering.
private let emptyProbes: [(CGFloat, CGFloat, Int)] = [
(20, 150, 5), // below column 0's last card the end slot
(150, 150, 5), // below column 1's last card the end slot too
(20, 82, 4), // the gap between card 2 and card 4, in column 0
(150, 64, 3), // the gap between card 1 and card 3, in column 1
(20, 200, 3), // below column 0's last card column 0's end, which is column 1's head
(150, 200, 5), // below column 1's last card the end slot, the lane's own end
(20, 113, 2), // the gap between card 1 and card 2, in column 0
(150, 25, 4), // the gap between card 3 and card 4, in column 1
(-60, 20, 0), // the lane's leading padding: still column 0
(400, 20, 1), // and its trailing padding: column 1
(104, 30, 1), // the gutter between the columns, which the band rule gives to column 1
(400, 20, 3), // and its trailing padding: column 1's first row
(104, 30, 4), // the gutter between the columns, which the band rule gives to column 1
]
@Test("The create slot is the card-drag zone, at the incoming run's nominal footprint")
@@ -526,7 +699,7 @@ struct FileDropZoneTests {
@Test("A card under the cursor attaches — anywhere on its bounds, closed at the edges")
func attachBeatsCreate() {
let probes: [(CGFloat, CGFloat, Int)] = [
(20, 20, 0), (150, 20, 1), (20, 60, 2), (150, 75, 3), (20, 100, 4),
(20, 20, 0), (20, 60, 1), (20, 130, 2), (150, 10, 3), (150, 40, 4),
]
for (x, y, expected) in probes {
for current in [nil, 0, 1, 2, 3, 4, 5] {
@@ -537,7 +710,7 @@ struct FileDropZoneTests {
// Closed containment: a cursor exactly on a shared edge still counts, and the first match
// wins, so the answer is deterministic however the frames abut.
#expect(landing(100, 40) == .attach(index: 0))
#expect(landing(108, 0) == .attach(index: 1))
#expect(landing(108, 0) == .attach(index: 3), "column 1's head, which is card 3")
}
/// **"A release on the lane header resolves to the topmost position"** (04-interactions.md,
@@ -550,16 +723,17 @@ struct FileDropZoneTests {
}
}
// The edge is the header's own, and one point below it the masonry answers again which is
// column 1's first row, the very reading the rule exists to override.
// column 1's first row (logical position 3), the very reading the rule exists to override.
#expect(landing(150, -20, headerBottom: -20) == .create(index: 0))
#expect(landing(150, -19, headerBottom: -20) == .create(index: 1))
#expect(landing(150, -19, headerBottom: -20) == .create(index: 3))
}
@Test("Without the header rule the stripe reads as a column, which is why the rule exists")
func noHeaderFrameLetsTheMasonryAnswer() {
// A lane whose header has not laid out yet: the masonry clamps inward to the nearest column,
// so the same cursor proposes column 1's first row rather than the top of the lane.
#expect(landing(150, -40, headerBottom: nil) == .create(index: 1))
// so the same cursor proposes column 1's first row rather than the top of the lane and
// under column-major that is logical position 3, a third of the way down the lane's order.
#expect(landing(150, -40, headerBottom: nil) == .create(index: 3))
#expect(landing(20, -40, headerBottom: nil) == .create(index: 0))
}
@@ -567,7 +741,7 @@ struct FileDropZoneTests {
func headerWinsOverACardBehindIt() {
// The masonry is scroll-view content: scrolled down, a card's resting frame can compute to a
// y the header stripe occupies. The ruling admits no exception, so the header answers.
#expect(landing(150, 20) == .attach(index: 1), "with no header the card takes it")
#expect(landing(150, 20) == .attach(index: 3), "with no header the card takes it")
#expect(landing(150, 20, headerBottom: 50) == .create(index: 0))
#expect(landing(20, 20, headerBottom: 50) == .create(index: 0))
}