import CoreGraphics /// The lane-reorder drag's geometry, as pure arithmetic — no view, no session, no snapshot /// (`LaneReorderMathTests`). `LaneLayoutMath`'s sibling: that one owns the resting layout and the /// right-edge resize, this one owns "where would the lane land if I let go now". /// /// **Geometry-based, so the proposal is stable rather than jittery** (04-interactions.md ▸ Drag and /// drop): the answer is a function of analytically computed resting positions and one pointer /// coordinate — never of measured mid-flight frames, which are garbage precisely during the reflow /// they trigger (03-board-ui.md § Motion, "Motion never feeds back into logic"). /// /// **Width-aware by construction.** The design asks for "no reflow until the cursor reaches where /// the dragged lane would actually land"; comparing against each remaining lane's *centre* is /// exactly that — a 3× lane's centre is three units along, so the drag has to travel most of that /// lane's width before the board proposes stepping past it, and a 1× lane yields quickly. /// /// ### What this deliberately is not /// /// The full drag model — the shadow's hold-until-a-new-candidate rule, multi-drag's N contiguous /// shadows, cross-board locality with its copy/move badge, and the mid-drag re-grounding rules — is /// **m5's drag card**, which replaces this file's callers with the real `DropSlot` /// (02-architecture.md § Layering ▸ Components). What is here is the within-board single-lane case /// and nothing else, deliberately small enough to be obviously correct. enum LaneReorderMath { /// Where the dragged lane would land: an index into the ordered live lanes **with the dragged /// lane removed**, so the result is in `0...(unitCounts.count - 1)` and `draggedIndex` itself /// means "back where it started". /// /// - Parameters: /// - unitCounts: the ordered live lanes' display units (`LaneLayoutMath.displayUnits`), the /// board as it currently is — recomputed against each snapshot rather than frozen at drag /// start, so a foreign lane add or tombstone mid-drag just moves the zones and the next /// proposal targets the board as it now is (04 ▸ Drag and drop, rule 1). /// - draggedIndex: the dragged lane's position in `unitCounts`. /// - dragCentreX: the dragged lane's centre under the cursor, in strip coordinates (0 at the /// strip's leading edge, outer margin included). /// - standard: the 1× lane width (`LaneLayoutMath.standardWidth`). /// - gap: the inter-lane gap, which is also the strip's outer margin. /// /// Out-of-range `draggedIndex` yields `0` rather than trapping: the lane vanished under the /// drag, and the caller's release-with-no-valid-proposal rule cancels anyway. static func proposedIndex( unitCounts: [Int], draggedIndex: Int, dragCentreX: CGFloat, standard: CGFloat, gap: CGFloat ) -> Int { guard unitCounts.indices.contains(draggedIndex) else { return 0 } var remaining = unitCounts remaining.remove(at: draggedIndex) // The remaining lanes' resting centres, left to right, in the layout they would have with // the dragged lane gone — which is the layout the siblings are already showing. // Monotonically increasing, so "how many centres has the cursor passed" is both the answer // and the reason it never oscillates: one threshold per slot, crossed once. var index = 0 var x = gap for units in remaining { let width = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap) guard dragCentreX > x + width / 2 else { break } index += 1 x += width + gap } return index } /// The resting centre of the lane at `index` in a strip of `unitCounts`, in the same strip /// coordinates `proposedIndex` reads. /// /// Two callers, and they are the two halves of the drag: the gesture freezes this at drag start /// as the origin its translation is measured from (the *physical pointer* being the only live /// input — 03 § Motion), and the view offsets the travelling lane from the centre it would rest /// at under the current proposal, which is what makes the replica track the cursor while the /// siblings sit in their would-be order. static func centre(ofLaneAt index: Int, unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> CGFloat { var x = gap for (position, units) in unitCounts.enumerated() { let width = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap) if position == index { return x + width / 2 } x += width + gap } return x } /// `unitCounts` (or any per-lane values) with the item at `from` moved to `to`, where `to` is /// counted **with the item already removed** — the ordering `proposedIndex` returns, applied. /// /// Shared by the view (which reorders the lanes it lays out, so the siblings show the would-be /// order) and by the drag's own centre arithmetic, so the two can never disagree about what the /// proposal means. static func reordered(_ items: [T], from: Int, to: Int) -> [T] { guard items.indices.contains(from) else { return items } var result = items let item = result.remove(at: from) result.insert(item, at: min(max(0, to), result.count)) return result } }