Files
lanework/Kanban/UI/Board/DropSlotMath.swift
T
rzen 21a5a6dbfd Build the drop-slot model and the drop commits — drag & drop, first half
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
2026-07-27 20:10:24 -04:00

288 lines
16 KiB
Swift
Raw 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 `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
}
}