The pathfinder's drag-reorder model, ported and generalized (DRAG-REORDER.md travels with it, rewritten for lanes, the interior masonry, multi-drag, cross-board sessions, the re-grounding trio, and the committed-overlay hold): - DropSlotMath — resting-layout zones from analytic lane arithmetic and the pure masonry placement (MasonryLayout now lays out through the same MasonryPlacement the drag reads, so geometry cannot drift), span-capped triggers sized to the dragged run's future footprint, hysteresis holds with the fresh-entry fallback, boundary ties, own-slot no-ops; nil means hold. - DragAutoScrollMath — the activation bands and velocity ramp, pure. - The drop commits, one performWrite bracket each: moveCards/copyCards within a board (insertion ranks touch only the dragged cards; renumber fallback); receiveCards/receiveLanes/receiveRestoredCards on the destination store for cross-board copy and ⌘-move with the import-boundary remint, lane copies stripping tombstoned cards while moves carry them; restoreByDrag is now positional, writing order only when the drop names a new one. Gestures, sessions, previews, and delegates are the second half. 773 unit tests (87 new since the keyboard grammar). Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
288 lines
16 KiB
Swift
288 lines
16 KiB
Swift
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 `LaneReorderMath.centre` 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 logical indices `c, c + C, c + 2C, …`; their vertical
|
||
/// extents feed the *same* span-capped 1D machinery the strip uses, with `draggedSpan` the
|
||
/// first dragged card's frozen height. Dead regions hold; the tail slot below the column's
|
||
/// last card is uncapped.
|
||
/// 3. **Logical index** — column `c`, row `r` is position `r * C + c`, clamped to
|
||
/// `heights.count`. Every column's tail slot maps at or past the end, so "below the last
|
||
/// card of any column" is the end slot: appending, which is the honest reading, since a
|
||
/// round-robin masonry has no landing spot below one column that is not simply the end.
|
||
///
|
||
/// - 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 }
|
||
let columns = placement.columnCount
|
||
|
||
// The proposal's own column, where it has one. The end slot belongs to every column's tail
|
||
// (each tail maps at or past the end), so it never rules a column out.
|
||
let currentColumn: Int? = {
|
||
guard let current, current >= 0, current < count else { return nil }
|
||
return placement.column(of: current)
|
||
}()
|
||
let column = columnIndex(atX: cursor.x, placement: placement, currentColumn: currentColumn)
|
||
|
||
let frames = placement.frames(heights: heights)
|
||
let positions = stride(from: column, to: count, by: columns).map { $0 }
|
||
let extents = positions.map { frames[$0].minY...frames[$0].maxY }
|
||
|
||
// The row this column would hold the current proposal at: its own row when the proposal
|
||
// lives in this column, this column's tail when the proposal is the end slot, and nothing
|
||
// when it belongs to another column — where a hold would be meaningless.
|
||
let currentRow: Int? = {
|
||
guard let current, current >= 0 else { return nil }
|
||
if current >= count { return positions.count }
|
||
return placement.column(of: current) == column ? placement.row(of: current) : nil
|
||
}()
|
||
|
||
guard let row = slot(cursor: cursor.y, extents: extents, gap: placement.spacing,
|
||
draggedSpan: draggedHeight, current: currentRow)
|
||
else { return nil }
|
||
|
||
return min(placement.index(column: column, row: row), count)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|