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):
|
/// 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.
|
/// 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
|
/// 2. **Row** — column `c`'s cards are the *contiguous* logical range `[start(c), start(c + 1))`
|
||||||
/// extents feed the *same* span-capped 1D machinery the strip uses, with `draggedSpan` the
|
/// (`MasonryPlacement.columnStart(_:itemCount:)`); their vertical extents feed the *same*
|
||||||
/// first dragged card's frozen height. Dead regions hold; the tail slot below the column's
|
/// span-capped 1D machinery the strip uses, with `draggedSpan` the first dragged card's
|
||||||
/// last card is uncapped.
|
/// 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
|
/// as is the region above its first.
|
||||||
/// `heights.count`. Every column's tail slot maps at or past the end, so "below the last
|
/// 3. **Logical index** — column `c`, row `r` is position `start(c) + r`, and no clamp is
|
||||||
/// card of any column" is the end slot: appending, which is the honest reading, since a
|
/// needed: `r` never exceeds the column's card count, so the answer never leaves
|
||||||
/// round-robin masonry has no landing spot below one column that is not simply the end.
|
/// `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:
|
/// - Parameters:
|
||||||
/// - cursor: the pointer in the same space as `placement.origin`.
|
/// - cursor: the pointer in the same space as `placement.origin`.
|
||||||
@@ -238,34 +241,34 @@ enum DropSlotMath {
|
|||||||
) -> Int? {
|
) -> Int? {
|
||||||
let count = heights.count
|
let count = heights.count
|
||||||
guard count > 0 else { return 0 }
|
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
|
// The proposal's own column, consulted only to settle an exact band tie. The end slot is
|
||||||
// (each tail maps at or past the end), so it never rules a column out.
|
// the last column's tail, so it names that column rather than no column at all.
|
||||||
let currentColumn: Int? = {
|
let currentColumn: Int? = {
|
||||||
guard let current, current >= 0, current < count else { return nil }
|
guard let current, (0...count).contains(current) else { return nil }
|
||||||
return placement.column(of: current)
|
return placement.column(of: current, itemCount: count)
|
||||||
}()
|
}()
|
||||||
let column = columnIndex(atX: cursor.x, placement: placement, currentColumn: currentColumn)
|
let column = columnIndex(atX: cursor.x, placement: placement, currentColumn: currentColumn)
|
||||||
|
|
||||||
let frames = placement.frames(heights: heights)
|
let frames = placement.frames(heights: heights)
|
||||||
let positions = stride(from: column, to: count, by: columns).map { $0 }
|
let start = placement.columnStart(column, itemCount: count)
|
||||||
let extents = positions.map { frames[$0].minY...frames[$0].maxY }
|
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
|
// The row this column would hold the current proposal at. `start...end` is exactly the set
|
||||||
// lives in this column, this column's tail when the proposal is the end slot, and nothing
|
// of logical positions this column's rows name — its own cards' positions plus its tail —
|
||||||
// when it belongs to another column — where a hold would be meaningless.
|
// so a proposal outside it belongs to another column, where a hold would be meaningless and
|
||||||
|
// the answer is nothing.
|
||||||
let currentRow: Int? = {
|
let currentRow: Int? = {
|
||||||
guard let current, current >= 0 else { return nil }
|
guard let current, (start...end).contains(current) else { return nil }
|
||||||
if current >= count { return positions.count }
|
return current - start
|
||||||
return placement.column(of: current) == column ? placement.row(of: current) : nil
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
guard let row = slot(cursor: cursor.y, extents: extents, gap: placement.spacing,
|
guard let row = slot(cursor: cursor.y, extents: extents, gap: placement.spacing,
|
||||||
draggedSpan: draggedHeight, current: currentRow)
|
draggedSpan: draggedHeight, current: currentRow)
|
||||||
else { return nil }
|
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
|
// MARK: - Applying a proposal
|
||||||
|
|||||||
@@ -704,20 +704,18 @@ struct LaneView: View {
|
|||||||
// holds identically under Reduce Motion: a transition that does not fire has no
|
// holds identically under Reduce Motion: a transition that does not fire has no
|
||||||
// variant to choose between.
|
// variant to choose between.
|
||||||
.transition(Motion.cardTransition(reduced: reduceMotion))
|
.transition(Motion.cardTransition(reduced: reduceMotion))
|
||||||
// **VoiceOver reads the masonry by `order`, not by column** — 10-accessibility.md
|
// **VoiceOver reads the masonry by `order`, not by drawn position** —
|
||||||
// ▸ Logical order, not masonry position (decided): "within a wide lane,
|
// 10-accessibility.md ▸ Logical order, not masonry position (decided).
|
||||||
// VoiceOver reads cards by `order` — the interior grid columns are presentation
|
|
||||||
// only. This deliberately diverges from on-screen geometry."
|
|
||||||
//
|
//
|
||||||
// The divergence is real and it is why an explicit priority is needed at all:
|
// The divergence narrowed when the masonry went column-major — walking down
|
||||||
// `MasonryLayout` assigns child `i` to column `i % columns`, so in a 3-unit lane
|
// one column now *is* consecutive `order` — but it is still real, and it is
|
||||||
// the second card by `order` is drawn to the *right* of the first, not below it
|
// why an explicit priority is needed at all: a geometry-sorted accessibility
|
||||||
// — and an accessibility tree sorted by geometry (which is what a container does
|
// tree (which is what a container does without this) sweeps in reading order,
|
||||||
// without this) would read the board column-major: 1, 4, 7, 2, 5, 8 …, an order
|
// left-to-right then down, which over a column-major grid interleaves the
|
||||||
// that exists nowhere in the model, on disk, or in the keyboard grammar.
|
// columns: 1, 4, 7, 2, 5, 8 …, an order that exists nowhere in the model, on
|
||||||
// Priority descends with the slot index, so the highest reads first and the list
|
// disk, or in the keyboard grammar. Priority descends with the slot index, so
|
||||||
// is exactly `slots` — the same sequence the masonry is handed and the same one
|
// the highest reads first and the list is exactly `slots` — the same sequence
|
||||||
// `SelectionGrammar` flattens.
|
// the masonry is handed and the same one `SelectionGrammar` flattens.
|
||||||
//
|
//
|
||||||
// The drag shadows are inert here: `DragShadow` hides itself from the tree, and
|
// The drag shadows are inert here: `DragShadow` hides itself from the tree, and
|
||||||
// a slot that is not an element consumes no priority.
|
// a slot that is not an element consumes no priority.
|
||||||
|
|||||||
@@ -11,10 +11,23 @@ import SwiftUI
|
|||||||
/// analytic-resting-layout rule (03-board-ui.md § Motion, "motion never feeds back into logic")
|
/// 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.
|
/// 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
|
/// **The assignment is column-major, and that is the whole model**: the children are dealt out in
|
||||||
/// `i % columnCount` at the bottom of that column's independent stack. Row `r` of column `c` is
|
/// contiguous runs, one run per column, filling each column top to bottom before starting the next.
|
||||||
/// therefore logical index `r * columnCount + c`, and the inverse is division — which is how a
|
/// With `n` children and `C` columns the runs are as even as they can be — `base = n / C`, and the
|
||||||
/// cursor position becomes an insertion index (`DropSlotMath.cardSlot`).
|
/// 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 {
|
struct MasonryPlacement: Equatable, Sendable {
|
||||||
|
|
||||||
/// Number of interior columns (the lane's width units); clamped to ≥ 1 at every use.
|
/// 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)
|
return max(0, (totalWidth - spacing * (count - 1)) / count)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The interior column child `index` is assigned to.
|
/// The logical index interior column `column` begins at, when `itemCount` children are dealt out
|
||||||
func column(of index: Int) -> Int { index % columnCount }
|
/// 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.
|
/// How many children interior column `column` holds — `base + 1` for the first `extra` columns,
|
||||||
func row(of index: Int) -> Int { index / columnCount }
|
/// `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:)`
|
/// The interior column child `index` is assigned to, in a grid of `itemCount` children — which
|
||||||
/// inverted. Unclamped: a caller asking for a column's tail row gets a position at or past
|
/// contiguous run `index` falls in, by division rather than by a scan.
|
||||||
/// the end, which is exactly what the end slot means.
|
///
|
||||||
func index(column: Int, row: Int) -> Int { row * columnCount + column }
|
/// 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`.
|
/// The leading x of interior column `column`.
|
||||||
func columnX(_ column: Int) -> CGFloat {
|
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.
|
/// 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] {
|
func frames(heights: [CGFloat]) -> [CGRect] {
|
||||||
var tops = [CGFloat](repeating: origin.y, count: columnCount)
|
var frames: [CGRect] = []
|
||||||
return heights.enumerated().map { index, height in
|
frames.reserveCapacity(heights.count)
|
||||||
let target = column(of: index)
|
for column in 0..<columnCount {
|
||||||
let frame = CGRect(x: columnX(target), y: tops[target], width: columnWidth, height: height)
|
let x = columnX(column)
|
||||||
tops[target] += height + spacing
|
var top = origin.y
|
||||||
return frame
|
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`
|
/// The grid's total height — the tallest column's stack, which is what `sizeThatFits`
|
||||||
/// reports.
|
/// reports.
|
||||||
func height(heights: [CGFloat]) -> CGFloat {
|
func height(heights: [CGFloat]) -> CGFloat {
|
||||||
var totals = [CGFloat](repeating: 0, count: columnCount)
|
var tallest: CGFloat = 0
|
||||||
for (index, height) in heights.enumerated() {
|
for column in 0..<columnCount {
|
||||||
let target = column(of: index)
|
var total: CGFloat = 0
|
||||||
totals[target] += height + (totals[target] > 0 ? spacing : 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
|
/// "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").
|
/// grid when wide — settled, the pathfinder's masonry works").
|
||||||
///
|
///
|
||||||
/// Children are assigned round-robin to `columns` equal-width vertical columns (child `i` → column
|
/// Children are dealt **column-major** into `columns` equal-width vertical columns — read top to
|
||||||
/// `i % columns`), and each column stacks its children top-aligned and independently — there is
|
/// bottom down one column, then across to the next — with the runs as even as they divide (the
|
||||||
/// **no row alignment across columns**. With uniform card heights this renders exactly like a
|
/// first `count % columns` columns take one extra child each; `MasonryPlacement`). Each column
|
||||||
/// row-major grid, but when one card grows taller than its neighbours (a longer title wrapping
|
/// stacks its children top-aligned and independently: there is **no row alignment across columns**.
|
||||||
/// across more lines, say) it only pushes the cards below it in its *own* column; the neighbouring
|
/// With uniform card heights this renders exactly like a newspaper's columns, but when one card
|
||||||
/// columns do not move.
|
/// 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
|
/// 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
|
/// `ForEach` — reflowing cards across columns preserves view identity and animates as positional
|
||||||
|
|||||||
@@ -275,47 +275,93 @@ struct LaneSlotTests {
|
|||||||
struct MasonryPlacementTests {
|
struct MasonryPlacementTests {
|
||||||
private let placement = MasonryPlacement(columnCount: 2, columnWidth: 100, spacing: 8)
|
private let placement = MasonryPlacement(columnCount: 2, columnWidth: 100, spacing: 8)
|
||||||
|
|
||||||
@Test("Children are assigned round-robin and each column stacks independently")
|
@Test("Children are dealt column-major and each column stacks independently")
|
||||||
func roundRobinStacking() {
|
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])
|
let frames = placement.frames(heights: [40, 60, 30, 20, 50])
|
||||||
#expect(frames == [
|
#expect(frames == [
|
||||||
CGRect(x: 0, y: 0, width: 100, height: 40), // column 0, row 0
|
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: 60), // column 0, row 1 — under card 0
|
||||||
CGRect(x: 0, y: 48, width: 100, height: 30), // column 0, row 1 — under card 0 only
|
CGRect(x: 0, y: 116, width: 100, height: 30), // column 0, row 2 — it holds the extra
|
||||||
CGRect(x: 108, y: 68, width: 100, height: 20), // column 1, row 1 — under card 1 only
|
CGRect(x: 108, y: 0, width: 100, height: 20), // column 1, row 0
|
||||||
CGRect(x: 0, y: 86, width: 100, height: 50),
|
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")
|
@Test("The placement matches an independent reading of the documented rule")
|
||||||
func differentialAgainstTheStatedRule() {
|
func differentialAgainstTheStatedRule() {
|
||||||
// A second implementation of the rule as 03-board-ui.md states it — "child `i` → column
|
// A second implementation of the rule as stated — the children dealt out in contiguous runs,
|
||||||
// `i % columns`, each column stacks top-aligned and independently" — written from the
|
// one per column, the first `n % C` columns taking one extra each, each column stacking
|
||||||
// words rather than from the code. `MasonryLayout` places subviews through
|
// top-aligned and independently — written from the words rather than from the code.
|
||||||
// `MasonryPlacement`, so agreeing here is agreeing with what is drawn.
|
// `MasonryLayout` places subviews through `MasonryPlacement`, so agreeing here is agreeing
|
||||||
|
// with what is drawn.
|
||||||
func naive(_ heights: [CGFloat], columns: Int, width: CGFloat, spacing: CGFloat,
|
func naive(_ heights: [CGFloat], columns: Int, width: CGFloat, spacing: CGFloat,
|
||||||
origin: CGPoint) -> [CGRect] {
|
origin: CGPoint) -> [CGRect] {
|
||||||
var stacks = [[CGFloat]](repeating: [], count: columns)
|
let base = heights.count / columns
|
||||||
|
let extra = heights.count % columns
|
||||||
var frames: [CGRect] = []
|
var frames: [CGRect] = []
|
||||||
for (index, height) in heights.enumerated() {
|
var next = 0
|
||||||
let column = index % columns
|
for column in 0..<columns {
|
||||||
let stacked = stacks[column].reduce(0) { $0 + $1 + spacing }
|
var stacked: [CGFloat] = []
|
||||||
frames.append(CGRect(x: origin.x + CGFloat(column) * (width + spacing),
|
for _ in 0..<(base + (column < extra ? 1 : 0)) {
|
||||||
y: origin.y + stacked,
|
frames.append(CGRect(x: origin.x + CGFloat(column) * (width + spacing),
|
||||||
width: width, height: height))
|
y: origin.y + stacked.reduce(0) { $0 + $1 + spacing },
|
||||||
stacks[column].append(height)
|
width: width, height: heights[next]))
|
||||||
|
stacked.append(heights[next])
|
||||||
|
next += 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return frames
|
return frames
|
||||||
}
|
}
|
||||||
|
|
||||||
let heights: [CGFloat] = [40, 60, 30, 20, 50, 55, 12]
|
let heights: [CGFloat] = [40, 60, 30, 20, 50, 55, 12]
|
||||||
for columns in 1...4 {
|
for count in 0...heights.count {
|
||||||
let origin = CGPoint(x: 17, y: 23)
|
for columns in 1...4 {
|
||||||
let placement = MasonryPlacement(columnCount: columns, columnWidth: 100,
|
let origin = CGPoint(x: 17, y: 23)
|
||||||
spacing: 8, origin: origin)
|
let placement = MasonryPlacement(columnCount: columns, columnWidth: 100,
|
||||||
#expect(placement.frames(heights: heights)
|
spacing: 8, origin: origin)
|
||||||
== naive(heights, columns: columns, width: 100, spacing: 8, origin: origin),
|
let slice = Array(heights.prefix(count))
|
||||||
"\(columns) interior columns")
|
#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() {
|
func heightIsTheTallestColumn() {
|
||||||
let heights: [CGFloat] = [40, 60, 30, 20, 50]
|
let heights: [CGFloat] = [40, 60, 30, 20, 50]
|
||||||
let frames = placement.frames(heights: heights)
|
let frames = placement.frames(heights: heights)
|
||||||
// Column 0 stacks 40 + 8 + 30 + 8 + 50 = 136; column 1 stacks 60 + 8 + 20 = 88.
|
// Column 0 stacks 40 + 8 + 60 + 8 + 30 = 146; column 1 stacks 20 + 8 + 50 = 78.
|
||||||
#expect(placement.height(heights: heights) == 136)
|
#expect(placement.height(heights: heights) == 146)
|
||||||
#expect(placement.height(heights: heights) == frames.map(\.maxY).max())
|
#expect(placement.height(heights: heights) == frames.map(\.maxY).max())
|
||||||
#expect(placement.height(heights: []) == 0)
|
#expect(placement.height(heights: []) == 0)
|
||||||
#expect(placement.frames(heights: []).isEmpty)
|
#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() {
|
func columnRowInversion() {
|
||||||
for index in 0..<9 {
|
// The mapping is a function of the child count, not of the index alone — so the round trip
|
||||||
#expect(placement.index(column: placement.column(of: index),
|
// has to hold at every count, not just the one the fixture happens to render.
|
||||||
row: placement.row(of: index)) == index)
|
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")
|
@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
|
// MARK: - The masonry's insertion index
|
||||||
|
|
||||||
/// A 2-wide lane of five cards, 100pt columns and 8pt spacing:
|
/// A 2-wide lane of five cards, 100pt columns and 8pt spacing. Column-major with `base = 2` and
|
||||||
/// column 0 (x 0…100): card 0 [0, 40] · card 2 [48, 78] · card 4 [86, 136]
|
/// `extra = 1`, so column 0 holds three cards and column 1 holds two:
|
||||||
/// column 1 (x 108…208): card 1 [0, 60] · card 3 [68, 88]
|
/// column 0 (x 0…100): card 0 [0, 40] · card 1 [48, 108] · card 2 [116, 146]
|
||||||
/// Column bands meet at 104. Column 0's zone boundaries are 44, 82, 140; column 1's are 64, 92.
|
/// column 1 (x 108…208): 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")
|
@Suite("DropSlotMath ▸ the card masonry")
|
||||||
struct CardSlotTests {
|
struct CardSlotTests {
|
||||||
private let placement = MasonryPlacement(columnCount: 2, columnWidth: 100, spacing: 8)
|
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")
|
@Test("A cursor over a card claims that card's logical position")
|
||||||
func cursorOverACardClaimsItsLogicalPosition() {
|
func cursorOverACardClaimsItsLogicalPosition() {
|
||||||
let probes: [(CGFloat, CGFloat, Int)] = [
|
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 (x, y, expected) in probes {
|
||||||
for current in [nil, 0, 1, 2, 3, 4, 5] {
|
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() {
|
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
|
// 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).
|
// traverses (10-accessibility.md's logical-order rule).
|
||||||
#expect(slot(150, 75, current: nil) == 3)
|
#expect(slot(150, 40, current: nil) == 4)
|
||||||
#expect(placement.column(of: 3) == 1)
|
#expect(placement.column(of: 4, itemCount: heights.count) == 1)
|
||||||
#expect(placement.row(of: 3) == 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")
|
@Test("A column's tail is that column's end — a real position, not the end of the lane")
|
||||||
func belowAColumnIsTheEndSlot() {
|
func aColumnsTailIsItsOwnEnd() {
|
||||||
#expect(slot(20, 200, current: nil) == 5, "below column 0 — clamped past the end")
|
// The column-major model's substantive gain over round-robin: below column 0 is a landing
|
||||||
#expect(slot(150, 200, current: nil) == 5, "below column 1 — exactly the end")
|
// spot *between* the columns, not an append. It is column 0's exclusive end, which is the
|
||||||
#expect(slot(20, 200, current: 1) == 5)
|
// 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")
|
@Test("Above and beside the grid clamp inward to the nearest column")
|
||||||
func clampingAtTheEdges() {
|
func clampingAtTheEdges() {
|
||||||
#expect(slot(20, -40, current: nil) == 0, "the lane header targets the first row")
|
#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(-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")
|
@Test("A dead region below a tall card holds the proposal")
|
||||||
func deadRegionHolds() {
|
func deadRegionHolds() {
|
||||||
// Dragging a 10pt card: slot 4's trigger runs from 82 for 10 + 8 → 100, so (100, 140) is
|
// Dragging a 10pt card: card 1 is 60pt tall, so slot 1's trigger runs from 44 for 10 + 8 →
|
||||||
// the far side of card 4's zone and changes nothing.
|
// 62, and (62, 112) is the far side of card 1's zone and changes nothing.
|
||||||
#expect(slot(20, 95, current: 0, dragged: 10) == 4, "inside the footprint, the slot triggers")
|
#expect(slot(20, 55, current: 0, dragged: 10) == 1, "inside the footprint, the slot triggers")
|
||||||
#expect(slot(20, 120, current: 0, dragged: 10) == nil)
|
#expect(slot(20, 90, current: 0, dragged: 10) == nil)
|
||||||
#expect(slot(20, 120, current: 2, 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.
|
// 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
|
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)
|
#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
|
// 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.
|
// 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: 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
|
var index = 0
|
||||||
for _ in 0..<10 { index = slot(20, 44, current: index, dragged: 60) ?? index }
|
for _ in 0..<10 { index = slot(20, 44, current: index, dragged: 60) ?? index }
|
||||||
#expect(index == 0, "the boundary pixel is a fixed point")
|
#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")
|
@Test("Re-evaluating a resting hover is a fixed point — own-slot pickup never reflows")
|
||||||
func ownSlotPickupIsANoOp() {
|
func ownSlotPickupIsANoOp() {
|
||||||
var index = 2
|
var index = 1
|
||||||
for _ in 0..<10 { index = slot(20, 60, current: index) ?? index }
|
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")
|
@Test("A proposal in another column never holds this one")
|
||||||
func aProposalInAnotherColumnDoesNotHold() {
|
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.
|
// 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 0…100): card 0 [0, 40] · card 1 [48, 88] · card 2 [96, 136]
|
||||||
|
/// column 1 (x 108…208): card 3 [0, 40] · card 4 [48, 88]
|
||||||
|
/// column 2 (x 216…316): 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")
|
@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
|
/// 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*.
|
/// 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):
|
/// The lane, exactly as `CardSlotTests` reads it — 2 columns, 100pt wide, 8pt spacing, origin (0, 0),
|
||||||
/// column 0 (x 0…100): card 0 [0, 40] · card 2 [48, 78] · card 4 [86, 136]
|
/// dealt column-major three cards to column 0 and two to column 1:
|
||||||
/// column 1 (x 108…208): card 1 [0, 60] · card 3 [68, 88]
|
/// column 0 (x 0…100): card 0 [0, 40] · card 1 [48, 108] · card 2 [116, 146]
|
||||||
|
/// column 1 (x 108…208): 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
|
/// 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
|
/// the nominal one — `LaneDropRegistry.nominalCardHeight`, the same stand-in a cross-board arrival
|
||||||
/// gets.
|
/// gets.
|
||||||
@@ -501,13 +674,13 @@ struct FileDropZoneTests {
|
|||||||
|
|
||||||
/// Every probe below that is **not** over a card, so the create branch is the one answering.
|
/// Every probe below that is **not** over a card, so the create branch is the one answering.
|
||||||
private let emptyProbes: [(CGFloat, CGFloat, Int)] = [
|
private let emptyProbes: [(CGFloat, CGFloat, Int)] = [
|
||||||
(20, 150, 5), // below column 0's last card — the end slot
|
(20, 200, 3), // below column 0's last card — column 0's end, which is column 1's head
|
||||||
(150, 150, 5), // below column 1's last card — the end slot too
|
(150, 200, 5), // below column 1's last card — the end slot, the lane's own end
|
||||||
(20, 82, 4), // the gap between card 2 and card 4, in column 0
|
(20, 113, 2), // the gap between card 1 and card 2, in column 0
|
||||||
(150, 64, 3), // the gap between card 1 and card 3, in column 1
|
(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
|
(-60, 20, 0), // the lane's leading padding: still column 0
|
||||||
(400, 20, 1), // and its trailing padding: column 1
|
(400, 20, 3), // and its trailing padding: column 1's first row
|
||||||
(104, 30, 1), // the gutter between the columns, which the band rule gives to column 1
|
(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")
|
@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")
|
@Test("A card under the cursor attaches — anywhere on its bounds, closed at the edges")
|
||||||
func attachBeatsCreate() {
|
func attachBeatsCreate() {
|
||||||
let probes: [(CGFloat, CGFloat, Int)] = [
|
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 (x, y, expected) in probes {
|
||||||
for current in [nil, 0, 1, 2, 3, 4, 5] {
|
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
|
// 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.
|
// wins, so the answer is deterministic however the frames abut.
|
||||||
#expect(landing(100, 40) == .attach(index: 0))
|
#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,
|
/// **"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
|
// 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, -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")
|
@Test("Without the header rule the stripe reads as a column, which is why the rule exists")
|
||||||
func noHeaderFrameLetsTheMasonryAnswer() {
|
func noHeaderFrameLetsTheMasonryAnswer() {
|
||||||
// A lane whose header has not laid out yet: the masonry clamps inward to the nearest column,
|
// 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.
|
// so the same cursor proposes column 1's first row rather than the top of the lane — and
|
||||||
#expect(landing(150, -40, headerBottom: nil) == .create(index: 1))
|
// 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))
|
#expect(landing(20, -40, headerBottom: nil) == .create(index: 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,7 +741,7 @@ struct FileDropZoneTests {
|
|||||||
func headerWinsOverACardBehindIt() {
|
func headerWinsOverACardBehindIt() {
|
||||||
// The masonry is scroll-view content: scrolled down, a card's resting frame can compute to a
|
// 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.
|
// 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(150, 20, headerBottom: 50) == .create(index: 0))
|
||||||
#expect(landing(20, 20, headerBottom: 50) == .create(index: 0))
|
#expect(landing(20, 20, headerBottom: 50) == .create(index: 0))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user