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
197 lines
9.7 KiB
Swift
197 lines
9.7 KiB
Swift
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").
|
|
///
|
|
/// Children are assigned round-robin to `columns` equal-width vertical columns (child `i` → column
|
|
/// `i % columns`), and each column stacks its children top-aligned and independently — there is
|
|
/// **no row alignment across columns**. With uniform card heights this renders exactly like a
|
|
/// row-major grid, but when one card grows taller (the sole selected card's attachment carousel,
|
|
/// 03-board-ui.md § Card face) 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
|
|
/// `ForEach` — reflowing cards across columns preserves view identity and animates as positional
|
|
/// moves, not as remove/insert transitions. That is what lets the interior reflow *during* a resize
|
|
/// drag read as cards sliding rather than blinking.
|
|
///
|
|
/// **Vocabulary note.** In Lanework a "lane" is the kanban column; this layout's own interior
|
|
/// tracks are "columns". The pathfinder called them lanes, which is why the ported reasoning below
|
|
/// reads the way it does.
|
|
struct MasonryLayout: Layout {
|
|
|
|
/// Number of interior columns (the lane's width units); clamped to ≥ 1.
|
|
var columns: Int
|
|
|
|
/// Spacing between columns and between stacked cards within a column.
|
|
var spacing: CGFloat
|
|
|
|
private var columnCount: Int { max(1, columns) }
|
|
|
|
private func columnWidth(for totalWidth: CGFloat) -> CGFloat {
|
|
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
|
|
//
|
|
// A lane with several hundred cards is measured a LOT: SwiftUI probes a layout's `sizeThatFits`
|
|
// more than once per pass (different proposals), and `placeSubviews` needs every height again
|
|
// right after. Unmemoized that is `subviews.count` full subtree measurements per call.
|
|
//
|
|
// The cache holds one height per subview, keyed by the column width they were measured at:
|
|
// column width is the only thing this layout ever proposes (height is always `nil`, so a card's
|
|
// height is a pure function of its width and its content). A different column width — a window
|
|
// resize, a width change — discards the whole table, which is correct and cheap: it is exactly
|
|
// the case where every height really did change.
|
|
//
|
|
// Staleness is handled by SwiftUI itself: `updateCache` runs whenever the layout's subviews
|
|
// change, which is the only way a card's measured height can change at a fixed column width
|
|
// (its content changed → its view tree was rebuilt → the layout's content is new). Clearing
|
|
// there means the cache never outlives the content it measured.
|
|
struct Cache {
|
|
var columnWidth: CGFloat = .nan
|
|
var heights: [CGFloat] = []
|
|
}
|
|
|
|
func makeCache(subviews: Subviews) -> Cache { Cache() }
|
|
|
|
func updateCache(_ cache: inout Cache, subviews: Subviews) {
|
|
cache = Cache()
|
|
}
|
|
|
|
/// `subviews[index]`'s height at `column` width, measured once per (content, column width)
|
|
/// generation and reused for every later probe and for the placement pass.
|
|
private func height(of subviews: Subviews, at index: Int, column: CGFloat, cache: inout Cache) -> CGFloat {
|
|
if cache.columnWidth != column || cache.heights.count != subviews.count {
|
|
cache.columnWidth = column
|
|
cache.heights = [CGFloat](repeating: .nan, count: subviews.count)
|
|
}
|
|
if !cache.heights[index].isNaN {
|
|
return cache.heights[index]
|
|
}
|
|
let height = subviews[index].sizeThatFits(ProposedViewSize(width: column, height: nil)).height
|
|
cache.heights[index] = height
|
|
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 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 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))
|
|
}
|
|
}
|
|
}
|