Files
rzen f174a524af 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
2026-07-31 07:34:30 -04:00

373 lines
20 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import CoreGraphics
/// Where a drag would land, as pure arithmetic — no views, no session, no snapshot
/// (`DropSlotMathTests`). The full model this implements is **DRAG-REORDER.md** at the repository
/// root; the reasoning is reproduced here only where a signature would otherwise be a puzzle.
///
/// Three ideas run through everything below:
///
/// - **Resting-layout zones.** The proposal is an insertion index into the *resting* layout — the
/// visible siblings laid out with the dragged run removed and no placeholder inserted. Slot `i`'s
/// zone is item `i`'s whole extent plus half the inter-item gap on each side; zones tile the
/// container, so a zone is entered exactly at its border and left only by entering another. The
/// zones are computed **analytically** — from unit counts and frozen heights, never from measured
/// frames — because measured frames are garbage precisely during the ~0.18s reflow a proposal
/// change triggers (03-board-ui.md § Motion, "motion never feeds back into logic").
/// - **Span-capped triggers.** Slot `i` triggers only while the cursor is over the span the dragged
/// run would *actually occupy* if dropped there — the shadow run's future footprint. The far side
/// of a wider item's zone is a **dead region** (04-interactions.md ▸ Drag and drop: "no reflow
/// until the cursor reaches where the dragged lane would actually land").
/// - **Hysteresis, spelled `nil`.** A dead region returns `nil`, which means *hold the current
/// proposal* — not "propose nothing" and not the caller's own value echoed back. The one
/// exception is a dead region hovered with no valid prior proposal (a fresh cross-board entry):
/// a drag in flight over a live target must always have *some* landing spot, so the containing
/// slot is proposed anyway.
///
/// Multi-drag is a single index for the whole run: the dragged items insert contiguously there, in
/// preserved flatten order (`SelectionGrammar.liveCards`). Nothing here knows how many shadows get
/// drawn — only how wide the run is (`draggedSpan`), which is what the cap is measured in.
enum DropSlotMath {
// MARK: - Zones (one axis, shared by both layouts)
/// The boundaries separating consecutive slot zones, ascending, computed from the items'
/// extents in the resting layout.
///
/// `boundaries[i]` separates slot `i` from slot `i + 1`: for interior neighbours it is the
/// midpoint of the gap between item `i` and item `i + 1` ("half the gap on each side"); the
/// final boundary is the last item's trailing edge plus half a `gap`, beyond which lies the end
/// slot.
///
/// - Parameters:
/// - extents: each visible item's span along the layout axis, in resting positions with the
/// dragged run already removed, ascending.
/// - gap: the layout's inter-item spacing.
static func zoneBoundaries(extents: [ClosedRange<CGFloat>], gap: CGFloat) -> [CGFloat] {
guard !extents.isEmpty else { return [] }
var boundaries: [CGFloat] = []
for index in 0..<(extents.count - 1) {
boundaries.append((extents[index].upperBound + extents[index + 1].lowerBound) / 2)
}
boundaries.append(extents[extents.count - 1].upperBound + gap / 2)
return boundaries
}
/// The slot (`0...boundaries.count`) whose zone contains `cursor` — the uncapped reading,
/// before any span cap applies.
///
/// - Parameters:
/// - cursor: pointer position along the layout axis, in `boundaries`' coordinate space.
/// - boundaries: `zoneBoundaries(extents:gap:)`, ascending.
/// - current: the currently proposed slot (`nil` when there is none). Consulted **only** to
/// break the tie when `cursor` sits on an exact boundary value: if `current` is one of the
/// two zones meeting there it is kept, so the shadow can never oscillate on a boundary
/// pixel.
static func containingSlot(cursor: CGFloat, boundaries: [CGFloat], current: Int?) -> Int {
let count = boundaries.count
guard count > 0 else { return 0 }
// Exact-boundary tie: the zones meeting at `cursor` are `b` and `b + 1`; keep the current
// proposal if it is one of them.
if let current,
let boundaryIndex = boundaries.firstIndex(of: cursor),
current == boundaryIndex || current == boundaryIndex + 1 {
return current
}
// Otherwise the containing zone: how many boundaries sit at or below the cursor (a zone is
// entered exactly at its border).
var index = 0
while index < count, cursor >= boundaries[index] { index += 1 }
return index
}
/// The span-capped slot for `cursor`, or `nil` to **hold** the current proposal.
///
/// Slot `i` triggers only while the cursor is over `[leading(i) gap/2, leading(i) +
/// draggedSpan + gap/2]` — where the dragged run would sit after a drop there. Past that the
/// zone is dead and this answers `nil`, so dragging a 1× lane across a 3× lane does not reflow
/// while the cursor is over the 3× lane's far side; the shadow stays where it was until the
/// cursor reaches a spot the run could really land.
///
/// Two slots are never capped: the **end slot** (past the last item — appending is the only
/// reading) and, by construction rather than by a special case, the region **before the first
/// item** (the cap only ever truncates a zone's far side, and slot 0's far side is inside the
/// container).
///
/// - Parameters:
/// - cursor: pointer position along the layout axis.
/// - extents: the visible items' resting spans with the dragged run removed, ascending.
/// - gap: the layout's inter-item spacing.
/// - draggedSpan: the dragged run's total extent when laid out — the sum of its items' spans
/// plus the gaps between them.
/// - current: the currently proposed slot, or `nil`. A dead region with no valid `current`
/// proposes the containing slot (the fresh-entry rule); with one, it answers `nil`.
/// - Returns: a slot in `0...extents.count`, or `nil` meaning "no change".
static func slot(
cursor: CGFloat,
extents: [ClosedRange<CGFloat>],
gap: CGFloat,
draggedSpan: CGFloat,
current: Int?
) -> Int? {
guard !extents.isEmpty else { return 0 }
let boundaries = zoneBoundaries(extents: extents, gap: gap)
let index = containingSlot(cursor: cursor, boundaries: boundaries, current: current)
guard index < extents.count else { return index } // end slot: uncapped
let triggerStart = extents[index].lowerBound - gap / 2
if cursor <= triggerStart + draggedSpan + gap { return index }
// Dead region. Hold — unless there is nothing to hold, in which case the containing zone
// is the answer: a drag in flight must always have some landing spot.
guard let current, (0...extents.count).contains(current) else { return index }
return nil
}
// MARK: - The lane strip
/// The lanes' resting extents along the strip, in strip coordinates (0 at the strip's leading
/// edge, the outer margin included) — the layout `unitCounts` would have if it were the whole
/// strip.
///
/// The strip's outer margin is one `gap`, so the first slot starts at `gap`; each lane is
/// `LaneLayoutMath.slotWidth(units:standard:gap:)` wide and one `gap` follows it. Same
/// arithmetic `LaneLayoutMath.laneIndex` walks, in range form.
///
/// `unitCounts` is the **visible lanes minus the dragged run**. `standard` is *not* recomputed
/// for that shorter list: it is a function of the board's unit total, and a lane in flight is
/// still a lane on the board (DRAG-REORDER.md § The lane strip's resting layout is arithmetic).
static func laneExtents(unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> [ClosedRange<CGFloat>] {
var extents: [ClosedRange<CGFloat>] = []
var left = gap
for units in unitCounts {
let width = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap)
extents.append(left...(left + width))
left += width + gap
}
return extents
}
/// The total extent a run of dragged lanes occupies when laid out — the sum of their slot
/// widths plus the `n 1` gaps between them. This is the span the trigger regions are capped
/// at, and it is exactly the shadow run's future footprint.
static func laneRunSpan(unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> CGFloat {
guard !unitCounts.isEmpty else { return 0 }
let widths = unitCounts.map { LaneLayoutMath.slotWidth(units: $0, standard: standard, gap: gap) }
return widths.reduce(0, +) + gap * CGFloat(unitCounts.count - 1)
}
/// Where a lane drag would land: an index into the ordered live lanes **with the dragged run
/// removed**, or `nil` to hold the current proposal.
///
/// - Parameters:
/// - cursorX: the pointer in strip coordinates. The *pointer*, not a measured replica frame
/// — 03-board-ui.md § Motion.
/// - restingUnits: the remaining lanes' display units (`LaneLayoutMath.displayUnits`), in
/// board order, recomputed against each snapshot rather than frozen at drag start so a
/// foreign lane add mid-drag just moves the zones (04-interactions.md ▸ Drag and drop,
/// rule 1).
/// - draggedUnits: the dragged lanes' display units, in the order they will land.
/// - standard: the 1× lane width (`LaneLayoutMath.standardWidth`) of the board being dropped
/// **into** — a cross-board arrival is measured in the destination's units.
/// - gap: the inter-lane gap, which is also the strip's outer margin.
/// - current: the currently proposed index, or `nil`.
static func laneSlot(
cursorX: CGFloat,
restingUnits: [Int],
draggedUnits: [Int],
standard: CGFloat,
gap: CGFloat,
current: Int?
) -> Int? {
slot(
cursor: cursorX,
extents: laneExtents(unitCounts: restingUnits, standard: standard, gap: gap),
gap: gap,
draggedSpan: laneRunSpan(unitCounts: draggedUnits, standard: standard, gap: gap),
current: current
)
}
// MARK: - The card masonry
/// Which interior column `x` falls in — `0..<placement.columnCount`, clamped, so the lane's
/// padding and the region above its grid target the nearest column rather than nothing.
///
/// The bands tile: column `c` plus half a spacing on each side. `currentColumn` breaks an
/// exact-boundary tie exactly as `containingSlot` does in 1D.
static func columnIndex(atX x: CGFloat, placement: MasonryPlacement, currentColumn: Int?) -> Int {
let count = placement.columnCount
guard count > 1 else { return 0 }
let boundaries = (0..<(count - 1)).map {
placement.columnX($0) + placement.columnWidth + placement.spacing / 2
}
return containingSlot(cursor: x, boundaries: boundaries, current: currentColumn)
}
/// Where a card drag would land in a lane's masonry: a position in the lane's **logical** card
/// order (`0...heights.count`), or `nil` to hold the current proposal.
///
/// 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 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`.
/// - placement: the lane's resting grid geometry.
/// - heights: the lane's rendered cards' heights **minus the dragged ones**, in logical
/// order, **frozen at drag start** — measured heights mid-flight are the animation-proof
/// rule's forbidden input.
/// - draggedHeight: the first dragged card's frozen height — the run's footprint at the
/// landing spot, which is the trigger rect the cursor is over.
/// - current: the currently proposed logical index, or `nil`.
static func cardSlot(
cursor: CGPoint,
placement: MasonryPlacement,
heights: [CGFloat],
draggedHeight: CGFloat,
current: Int?
) -> Int? {
let count = heights.count
guard count > 0 else { return 0 }
// 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, (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 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. `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, (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 placement.index(column: column, row: row, itemCount: count)
}
// MARK: - Applying a proposal
/// `items` with the members at `moving` lifted out and re-inserted contiguously at `index`,
/// where `index` is counted **with them already removed** — the convention every proposal and
/// every drop commit in this app shares.
///
/// The lifted members keep their given order (flatten order at the call sites), which is what
/// "drop inserts contiguously in preserved relative order" means (04-interactions.md ▸ Drag and
/// drop). Shared by the geometry's callers and by `BoardStore`'s no-op guard, so the shadow's
/// arrangement and the arrangement the store refuses to rewrite can never disagree.
static func applied<T: Equatable>(_ items: [T], moving: [T], to index: Int) -> [T] {
var remaining = items.filter { !moving.contains($0) }
let target = min(max(0, index), remaining.count)
remaining.insert(contentsOf: moving, at: target)
return remaining
}
}
// MARK: - A Finder file drag's zones
/// Where an external **Finder file** drag resolves inside one lane, as pure arithmetic
/// (`FileDropZoneTests`) — the three answers a lane's own geometry gives it.
///
/// **Drops are positional everywhere** (04-interactions.md ▸ Drag and drop, settled 2026-07-28):
/// "created cards land at the drop position — resolved through the same card-grid zones an ordinary
/// card drag uses, shadow included", and append-at-bottom stays the creation *trio*'s rule (⌘N,
/// Return, a double click on empty space), not the drop's. So the create landing is
/// `DropSlotMath.cardSlot` and nothing else: the very function a card drag proposes through, with the
/// incoming run's **nominal** footprint standing in for the frozen height a card drag freezes at
/// pickup — the cards being proposed do not exist yet to have been measured, exactly as a cross-board
/// arrival's do not.
///
/// Kept out of `BoardDropContext` for `TrashDrop`'s reason: the ruling is then checkable without a
/// window, and the hover and the release read one answer rather than two that can drift.
enum FileDropZones {
/// What the lane's geometry says the files would become.
enum Landing: Equatable, Sendable {
/// The cursor is over the card at this position in the lane's **logical** card order — the
/// files join its `attachments/`. Attach beats create anywhere on a card's bounds.
case attach(index: Int)
/// One card per file, opening at this position in the logical card order.
case create(index: Int)
/// A dead region (`DropSlotMath.slot`'s `nil`): **hold** whatever the create slot already was.
case hold
}
/// Resolves `cursor` against one lane, in three questions asked in this order.
///
/// 1. **The lane header is the topmost position** (04-interactions.md ▸ Drag and drop, settled
/// 2026-07-28: "a release on the lane header resolves to the topmost position — forgiving beats
/// a dead stripe: the header's chrome roles don't collide with a file payload"). The header
/// does not scroll, so its own edge is the honest boundary; the accent band and the plate's top
/// padding sit above it and are its chrome, which is why the test is *at or above* rather than
/// containment. It is asked **first** because a scrolled masonry can place a card's analytic
/// frame behind the header stripe, and the ruling admits no exception there.
/// 2. **A card under the cursor always wins** over the lane behind it. The bounds are the resting
/// frames `MasonryPlacement.frames(heights:)` replays — the same reconstruction the slot zones
/// are built from, never a measured frame (03-board-ui.md § Motion). Closed containment, first
/// match wins, so the answer is deterministic however the frames abut.
/// 3. **Otherwise the create slot**, from `DropSlotMath.cardSlot`.
///
/// - Parameters:
/// - cursor: the pointer, in `placement.origin`'s space.
/// - headerBottom: the lane header's bottom edge in that same space, or `nil` for a lane whose
/// header has not registered a frame yet — where the masonry answers alone.
/// - placement: the lane's resting grid geometry.
/// - heights: the lane's rendered cards' heights, in logical order.
/// - nominalHeight: the height an incoming, unmeasured card is assumed to have — the span the
/// create zone's cap is measured against, and the height each shadow draws at.
/// - current: the create slot currently proposed for this lane, or `nil`.
static func landing(
cursor: CGPoint,
headerBottom: CGFloat?,
placement: MasonryPlacement,
heights: [CGFloat],
nominalHeight: CGFloat,
current: Int?
) -> Landing {
if let headerBottom, cursor.y <= headerBottom { return .create(index: 0) }
let frames = placement.frames(heights: heights)
if let index = frames.firstIndex(where: { frame in
cursor.x >= frame.minX && cursor.x <= frame.maxX
&& cursor.y >= frame.minY && cursor.y <= frame.maxY
}) {
return .attach(index: index)
}
guard let slot = DropSlotMath.cardSlot(
cursor: cursor,
placement: placement,
heights: heights,
draggedHeight: nominalHeight,
current: current
) else { return .hold }
return .create(index: slot)
}
}