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
This commit is contained in:
2026-07-27 20:10:24 -04:00
parent 4035ba7986
commit 21a5a6dbfd
14 changed files with 2641 additions and 55 deletions
+112 -18
View File
@@ -1,5 +1,88 @@
import CoreGraphics
import SwiftUI
/// Where a masonry puts its children, as pure arithmetic no views, no `Layout`, no measurement
/// (`MasonryPlacementTests`).
///
/// `MasonryLayout` below *is* this function plus SwiftUI's measurement cache, and the drag model
/// reconstructs a lane's resting card grid by replaying it over the frozen heights
/// (DRAG-REORDER.md § The card masonry). Extracting it is what makes those two the same
/// arithmetic rather than two implementations that agree until one of them is edited the
/// 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.
///
/// **The assignment is round-robin, and that is the whole model**: child `i` lands in column
/// `i % columnCount` at the bottom of that column's independent stack. Row `r` of column `c` is
/// therefore logical index `r * columnCount + c`, and the inverse is division which is how a
/// cursor position becomes an insertion index (`DropSlotMath.cardSlot`).
struct MasonryPlacement: Equatable, Sendable {
/// Number of interior columns (the lane's width units); clamped to 1 at every use.
let columnCount: Int
/// One column's width the standard card width, since every card is one column wide.
let columnWidth: CGFloat
/// Spacing between columns and between stacked cards within a column.
let spacing: CGFloat
/// The grid's top-leading corner, in whatever space the caller is working in.
let origin: CGPoint
init(columnCount: Int, columnWidth: CGFloat, spacing: CGFloat, origin: CGPoint = .zero) {
self.columnCount = max(1, columnCount)
self.columnWidth = columnWidth
self.spacing = spacing
self.origin = origin
}
/// The column width `columnCount` columns and their interior spacings divide `totalWidth`
/// into `MasonryLayout`'s own expression, floored at zero so a lane narrower than its
/// spacings never proposes a negative width.
static func columnWidth(totalWidth: CGFloat, columnCount: Int, spacing: CGFloat) -> CGFloat {
let count = CGFloat(max(1, columnCount))
return max(0, (totalWidth - spacing * (count - 1)) / count)
}
/// The interior column child `index` is assigned to.
func column(of index: Int) -> Int { index % columnCount }
/// The row within its column child `index` stacks at.
func row(of index: Int) -> Int { index / columnCount }
/// The logical position that row `row` of column `column` holds `column(of:)`/`row(of:)`
/// inverted. Unclamped: a caller asking for a column's tail row gets a position at or past
/// the end, which is exactly what the end slot means.
func index(column: Int, row: Int) -> Int { row * columnCount + column }
/// The leading x of interior column `column`.
func columnX(_ column: Int) -> CGFloat {
origin.x + CGFloat(column) * (columnWidth + spacing)
}
/// Every child's frame, in child order, for children of the given heights.
func frames(heights: [CGFloat]) -> [CGRect] {
var tops = [CGFloat](repeating: origin.y, count: columnCount)
return heights.enumerated().map { index, height in
let target = column(of: index)
let frame = CGRect(x: columnX(target), y: tops[target], width: columnWidth, height: height)
tops[target] += height + spacing
return frame
}
}
/// The grid's total height the tallest column's stack, which is what `sizeThatFits`
/// reports.
func height(heights: [CGFloat]) -> CGFloat {
var totals = [CGFloat](repeating: 0, count: columnCount)
for (index, height) in heights.enumerated() {
let target = column(of: index)
totals[target] += height + (totals[target] > 0 ? spacing : 0)
}
return totals.max() ?? 0
}
}
/// Masonry layout for a lane's interior card columns (03-board-ui.md § Layout full visibility:
/// "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").
@@ -30,7 +113,16 @@ struct MasonryLayout: Layout {
private var columnCount: Int { max(1, columns) }
private func columnWidth(for totalWidth: CGFloat) -> CGFloat {
max(0, (totalWidth - spacing * CGFloat(columnCount - 1)) / CGFloat(columnCount))
MasonryPlacement.columnWidth(totalWidth: totalWidth, columnCount: columnCount, spacing: spacing)
}
/// The placement arithmetic for a grid of `width` points at `origin` the one expression both
/// this layout and the drag model's resting grid go through (`MasonryPlacement`).
private func placement(width: CGFloat, origin: CGPoint) -> MasonryPlacement {
MasonryPlacement(columnCount: columnCount,
columnWidth: columnWidth(for: width),
spacing: spacing,
origin: origin)
}
// MARK: - Measurement cache
@@ -75,28 +167,30 @@ struct MasonryLayout: Layout {
return height
}
/// Every subview's height at `column` width, in subview order the input `MasonryPlacement`
/// takes, gathered through the cache above so both passes measure once between them.
private func measuredHeights(of subviews: Subviews, at column: CGFloat, cache: inout Cache) -> [CGFloat] {
var heights: [CGFloat] = []
heights.reserveCapacity(subviews.count)
for index in subviews.indices {
heights.append(height(of: subviews, at: index, column: column, cache: &cache))
}
return heights
}
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize {
let width = proposal.width ?? 0
let column = columnWidth(for: width)
var heights = [CGFloat](repeating: 0, count: columnCount)
for index in subviews.indices {
let height = height(of: subviews, at: index, column: column, cache: &cache)
let target = index % columnCount
heights[target] += height + (heights[target] > 0 ? spacing : 0)
}
return CGSize(width: width, height: heights.max() ?? 0)
let placement = placement(width: width, origin: .zero)
let heights = measuredHeights(of: subviews, at: placement.columnWidth, cache: &cache)
return CGSize(width: width, height: placement.height(heights: heights))
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
let column = columnWidth(for: bounds.width)
var y = [CGFloat](repeating: bounds.minY, count: columnCount)
for index in subviews.indices {
let target = index % columnCount
let x = bounds.minX + CGFloat(target) * (column + spacing)
let height = height(of: subviews, at: index, column: column, cache: &cache)
subviews[index].place(at: CGPoint(x: x, y: y[target]),
proposal: ProposedViewSize(width: column, height: height))
y[target] += height + spacing
let placement = placement(width: bounds.width, origin: bounds.origin)
let heights = measuredHeights(of: subviews, at: placement.columnWidth, cache: &cache)
for (index, frame) in placement.frames(heights: heights).enumerated() {
subviews[index].place(at: frame.origin,
proposal: ProposedViewSize(width: frame.width, height: frame.height))
}
}
}