import CoreGraphics /// The board strip's geometry, as pure arithmetic — no view, no window, no state /// (`LaneLayoutMathTests`). /// /// Two rules from 03-board-ui.md meet here, and they are deliberately *different* mechanisms /// sharing one set of numbers: /// /// - **Full visibility** (§ Layout — full visibility): the window's width divides across the lanes' /// width units, so `standardWidth` is the whole of the resting layout. There is no horizontal /// scroll and no minimum lane width to honour — enough units in a small window compress every /// lane, and that is accepted rather than floored. /// - **The right-edge drag** (§ Lane): a snap between whole unit counts that grows or shrinks the /// *window* by one standard width per tick, so the other lanes keep their exact pixels. /// `slotWidth`, `snappedUnits`, `resistedWidth` and `maxUnits` are that interaction's arithmetic, /// ported from the pathfinder's proven `ColumnResizeMath` (its reasoning is reproduced below, /// since the behaviour is what was proven, not the code). /// /// **The two mechanisms meet at the screen's visible frame** (settled 2026-08-08, § Lane): once the /// window can grow no further the drag stops moving it and degrades to the re-divide — the same /// fixed width across one more unit per tick, siblings compressing, which is precisely what the /// stepper does. `pinnedStripWidth`, `resizeStandard`, `resizeMaxUnits` and `resizeWindowDelta` are /// that second regime; they are written so the boundary itself costs no pixels, since the re-divided /// standard at the screen fit *is* the frozen standard. /// /// The one behavioural difference from the pathfinder: **Lanework has no upper width cap.** A lane /// spans any whole number of units ≥ 1, so `allowedRange`'s ceiling is not a width range but the /// strip's own capacity (`resizeMaxUnits`) — there is no `Column.widthRange` equivalent to fold in, /// and shrinking is always allowed. enum LaneLayoutMath { // MARK: - The resting layout /// The 1× (one unit) lane width for a strip `stripWidth` points wide laying out `totalUnits` /// whole units with `gap` between lanes **and `gap` again outside the first and the last** — /// hence `totalUnits + 1` gaps: the `totalUnits - 1` interior ones plus the strip's two outer /// margins. The strip therefore always exactly fills, which is what "every lane is always on /// screen" means arithmetically (03-board-ui.md § Layout — full visibility). /// /// **Floored at 1pt, and at nothing else.** The design is explicit that the degenerate case is /// accepted, not floored: a minimum lane width would reintroduce horizontal scroll, which was /// considered in the pathfinder and deliberately rejected. The 1pt floor exists only so a frame /// is never zero or negative — the pathological input (a strip narrower than its own gaps) must /// not produce a negative size for SwiftUI to complain about. static func standardWidth(stripWidth: CGFloat, totalUnits: Int, gap: CGFloat) -> CGFloat { let count = CGFloat(max(1, totalUnits)) return max(1, (stripWidth - gap * (count + 1)) / count) } /// The rendered width of a `units`-unit lane: `units` standard widths plus the `units - 1` /// interior gaps it swallows. `BoardView`'s per-lane frame is this exact expression, so a /// snapped slot and a committed lane are the same pixels. static func slotWidth(units: Int, standard: CGFloat, gap: CGFloat) -> CGFloat { standard * CGFloat(units) + gap * CGFloat(units - 1) } /// The whole units a lane spans on screen: its `width` when that read as a valid integer, 1 /// otherwise. /// /// `Lane.width` is a **lenient** field (01-storage-format.md § Frontmatter): a missing key or /// a non-numeric/fractional value arrives here as `.missing` or `.malformed` and renders as /// one unit, while an exact-integer reading below 1 (zero, negative) is no longer malformed at /// all — it coerces to 1 at the read side (**ranges are part of the sensible reading**, /// settled). Either way the bytes on disk are left exactly as the author wrote them until the /// user actually changes the width, at which point the Writer replaces them with an integer /// (`BoardStore.setLaneWidth`). The `max(1,)` is belt over braces: the read side already never /// produces anything below 1, and this function is the single place the rest of the UI asks /// "how many units does this lane span". static func displayUnits(of lane: Lane) -> Int { max(1, lane.width.value ?? 1) } /// The unit total a strip of `lanes` divides across — the sum of their display units, never /// below 1 so `standardWidth` cannot be handed a zero divisor for an empty board. /// /// The caller decides *which* lanes: the strip passes the snapshot's, in order. There is no /// liveness question left to ask — "Cards only. Lanes are never trashed" (03-board-ui.md § /// Trash), so every lane the snapshot holds is a lane on screen consuming its units. /// /// **`trashUnits` is the trash column's fixed one unit, and it is *only* consumed while shown** /// (03-board-ui.md § Trash): the trash "spans a fixed one width unit — no `width` frontmatter, /// and neither the stepper nor the edge drag applies — consumed only while shown". Passing it /// here rather than fabricating a `Lane` for the trash is what keeps that true: there is no lane /// value anywhere that a reorder, a resize or a width write could reach. /// /// Show/Hide Trash is therefore a **re-divide trigger** and nothing more — the window is /// untouched, and the existing width divides across one more (or one fewer) unit, exactly as a /// lane add does (§ Layout — full visibility). static func totalUnits(of lanes: [Lane], trashUnits: Int = 0) -> Int { max(1, lanes.reduce(0) { $0 + displayUnits(of: $1) } + max(0, trashUnits)) } // MARK: - Hit testing /// Which lane sits under `x` in strip coordinates (0 at the strip's leading edge, outer margin /// included) — an index into `unitCounts`, or `nil` when `x` is not over a lane at all. /// /// **The gaps and the margins answer `nil` deliberately**, and so does everything past the last /// lane — which is where the trash column sits. That is the whole of drag-to-restore's /// "a drop anywhere else is a no-op" (03-board-ui.md § Trash): a drop that does not land /// squarely on a live lane writes nothing rather than guessing at the nearest one. /// /// Same analytic geometry as `DropSlotMath.laneExtents` — resting positions computed from /// the unit counts, never measured frames (03-board-ui.md § Motion, "motion never feeds back /// into logic"). static func laneIndex(atX x: CGFloat, unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> Int? { var left = gap for (index, units) in unitCounts.enumerated() { let width = slotWidth(units: units, standard: standard, gap: gap) if x >= left, x < left + width { return index } left += width + gap } return nil } // MARK: - The drag's snap /// The snapped unit count after a live-width change: `currentUnits` unless the live width has /// moved far enough into "shadow leads" territory for an adjacent slot, in which case it ticks /// by exactly one. /// /// This is an ASYMMETRIC snap, not a midpoint-±-band around a boundary: the shadow (the snapped /// count, which sizes the visible slot) leads the live edge going up and deliberately lags it /// coming back down. /// /// • Tick UP fires the instant the live edge clears the FAR side of the gap that trails slot /// `k` — `liveWidth > slotWidth(k) + gap` — which is exactly where the next lane's content /// would start. The moment the cursor has eaten the whole gap, the bigger slot is already /// the honest read of what is under it, so the shadow jumps there right away: it never /// lags, and the live edge can only ever overhang the shadow by at most one gap width, /// transiently, in the instant just before a tick. /// • Tick DOWN fires only once the live edge has retreated `reentry` (10pt, fixed) back INTO /// that same gap — `liveWidth < slotWidth(k - 1) + gap - reentry` — rather than at the /// mirror image of the tick-up threshold. Shrinking back the instant the edge re-enters the /// gap it just cleared would flap the window on the smallest jitter right at the crossing; /// requiring a real 10pt of retreat means the cursor has to mean it. (03-board-ui.md § Lane /// names exactly this: "shadow snaps at the inter-column gap with 10pt release hysteresis".) /// /// `reentry` is the ONLY hysteresis in this design — it exists to give the tick-down threshold /// room, not to make the two thresholds symmetric. Stability follows from the two thresholds /// never meeting: for shadow `k` the hold band is `(slotWidth(k - 1) + gap - reentry, /// slotWidth(k) + gap]`, which stays non-empty as long as `standard` is many times larger than /// 10pt (true of every lane width a real window produces), so calling this on every drag event /// never oscillates. /// /// Ticks are capped to `allowedRange`, whose ceiling is the strip's capacity (`resizeMaxUnits`) /// — there is no width cap to respect (03-board-ui.md § Lane: "1×, 2×, 3×, … — no cap"), and /// past the screen fit the tick keeps firing, re-dividing rather than growing the window. /// /// **`slotFor` rather than a standard**, because the standard is no longer one number for the /// whole gesture: beyond the screen fit each further unit re-divides the pinned strip, so slot /// `k` and slot `k + 1` are measured against *different* standards (`resizeStandard`). The /// thresholds are unchanged in form — they simply ask the caller how wide each slot would be. /// The band stays non-empty in the re-divide too: the slots still grow strictly with `k`, since /// a unit added to a lane takes more from the strip than the re-divide gives back. static func snappedUnits( liveWidth: CGFloat, currentUnits: Int, slotFor: (Int) -> CGFloat, gap: CGFloat, allowedRange: ClosedRange, reentry: CGFloat ) -> Int { if currentUnits < allowedRange.upperBound, liveWidth > slotFor(currentUnits) + gap { return currentUnits + 1 } if currentUnits > allowedRange.lowerBound { if liveWidth < slotFor(currentUnits - 1) + gap - reentry { return currentUnits - 1 } } return currentUnits } /// The same snap where every slot is measured against one standard — the whole of the gesture /// below the screen fit, and the shape the hit-testing and layout call sites think in. static func snappedUnits( liveWidth: CGFloat, currentUnits: Int, standard: CGFloat, gap: CGFloat, allowedRange: ClosedRange, reentry: CGFloat ) -> Int { snappedUnits( liveWidth: liveWidth, currentUnits: currentUnits, slotFor: { slotWidth(units: $0, standard: standard, gap: gap) }, gap: gap, allowedRange: allowedRange, reentry: reentry) } /// The live width rubber-banded to stay near the allowed slot range: inside `[minSlot, /// maxSlot]` the proposed width passes through untouched; beyond either end only `resistance` /// (0.25) of the overshoot is applied, so the edge visibly resists but still gives, signalling /// the bound without a hard stop (03-board-ui.md § Lane: the rubber band "moves to the true end /// of travel — the strip's own capacity"). The snap tick never follows the width past the bound /// (see `snappedUnits`' clamp), so this is purely cosmetic give. static func resistedWidth( proposed: CGFloat, minSlot: CGFloat, maxSlot: CGFloat, resistance: CGFloat ) -> CGFloat { if proposed < minSlot { return minSlot - (minSlot - proposed) * resistance } if proposed > maxSlot { return maxSlot + (proposed - maxSlot) * resistance } return proposed } /// The largest unit count that fits on screen: `currentUnits` plus as many whole `step` /// (= standard + gap) growths as the window has room to expand into before its right edge would /// pass the screen's visible frame. **Never less than `currentUnits`** — shrinking is always /// allowed regardless of screen room, including from a window already hanging off the edge /// (negative headroom). /// /// Unlike the pathfinder's twin this is **not the tick's ceiling** — it is the boundary where /// one mechanism hands over to the other (settled 2026-08-08, 03-board-ui.md § Lane). Up to it /// the drag grows the window and the siblings keep their pixels; past it the window is spent and /// each further tick re-divides instead (`resizeStandard`), which is how a lane keeps growing at /// the siblings' expense on a full screen. A window with no headroom at all answers /// `currentUnits`, so the very first tick is already a re-divide. /// /// Pure so it can be unit-tested; the session computes `headroom` from the live window and its /// screen and defers the arithmetic here. static func maxUnits(currentUnits: Int, headroom: CGFloat, step: CGFloat) -> Int { guard step > 0, headroom.isFinite else { return currentUnits } let extra = Int(floor(max(0, min(headroom, CGFloat(Int.max) / 2)) / step)) return max(currentUnits, currentUnits + extra) } // MARK: - The drag past the screen: the re-divide /// The strip's width once the window has grown as far as its screen allows — the width every /// tick past the screen fit re-divides, since the window is pinned from there on. /// /// The drag-start width is **derived, not measured**: the strip always fills exactly /// (`standardWidth`), so a frozen standard `s` over `T` units means a strip of `s·T + g·(T + 1)` /// and nothing else. Growth adds one `s + g` step per unit of on-screen headroom. Deriving it is /// what makes the boundary free: a measured viewport width, carrying whatever half-point the /// layout rounded to, would put a visible step where the two regimes meet. static func pinnedStripWidth( startUnits: Int, startStandard: CGFloat, startTotalUnits: Int, fittingUnits: Int, gap: CGFloat ) -> CGFloat { let total = CGFloat(max(1, startTotalUnits)) let atStart = startStandard * total + gap * (total + 1) return atStart + CGFloat(max(0, fittingUnits - startUnits)) * (startStandard + gap) } /// The strip's standard (1×) width part-way through a right-edge drag, for a dragged lane /// spanning `units` — the whole of the drag's two regimes in one function (03-board-ui.md /// § Lane, settled 2026-08-08). /// /// At or below the screen fit the answer is the standard frozen at drag start: the window takes /// the step, so the division is unchanged and every other lane keeps its exact pixels. Past the /// fit the window is pinned, so each further unit the dragged lane claims is one more unit the /// same `pinnedStripWidth` has to divide across — the siblings compress, which is the stepper's /// mechanism arriving under the drag's fingers. /// /// **The regimes meet with no pixel jump.** At `units == fittingUnits` the pinned width divided /// across its own unit total is `startStandard` exactly, by the exact-fill identity /// `pinnedStripWidth` is built from — the same reason the release hands back to the resting /// layout without a flinch (`LaneResizeSession`). static func resizeStandard( forUnits units: Int, startUnits: Int, startStandard: CGFloat, startTotalUnits: Int, fittingUnits: Int, gap: CGFloat ) -> CGFloat { guard units > fittingUnits else { return startStandard } return standardWidth( stripWidth: pinnedStripWidth( startUnits: startUnits, startStandard: startStandard, startTotalUnits: startTotalUnits, fittingUnits: fittingUnits, gap: gap), totalUnits: max(1, startTotalUnits) + (units - startUnits), gap: gap) } /// The drag's ceiling — the true end of travel, which past the screen fit is a question about /// the strip's capacity rather than about the screen. /// /// The re-divide can always take one more unit, but not usefully forever: at some total /// `standardWidth`'s 1pt floor engages and the strip stops filling exactly, which is the point /// where the arithmetic stops describing anything on screen. That total is the largest `T` /// satisfying `(W − g·(T + 1)) / T ≥ 1`, i.e. `floor((W − g) / (1 + g))`, and the dragged lane's /// ceiling is that total read back through the units it contributed. This is where the rubber /// band now sits (§ Lane: "the true end of travel — the strip's own capacity"). /// /// **Never below the screen fit**, and so never below the count the drag started from — /// shrinking is always allowed. A degenerate strip (a non-finite standard, a gap at or below /// −1pt, a capacity under one whole unit) falls back to the fit rather than inventing a bound. static func resizeMaxUnits( startUnits: Int, startStandard: CGFloat, startTotalUnits: Int, fittingUnits: Int, gap: CGFloat ) -> Int { let fit = max(startUnits, fittingUnits) guard startStandard.isFinite, gap.isFinite, gap > -1 else { return fit } let width = pinnedStripWidth( startUnits: startUnits, startStandard: startStandard, startTotalUnits: startTotalUnits, fittingUnits: fittingUnits, gap: gap) guard width.isFinite else { return fit } let capacity = (width - gap) / (1 + gap) guard capacity >= 1 else { return fit } let total = Int(min(capacity, CGFloat(Int.max) / 2).rounded(.down)) return max(fit, startUnits + (total - max(1, startTotalUnits))) } /// How far the window moves on a tick from `oldUnits` to `newUnits`: one `step` per unit of that /// change lying **inside** the screen fit, and nothing at all for the part beyond it, where the /// re-divide has taken over and the window is pinned (03-board-ui.md § Lane). /// /// A difference of clamped counts rather than a per-step walk, because a flick can cross the /// boundary in one gesture event and the two sides must net out exactly: `F − 1 → F + 2` is one /// step (only the first unit was ever the window's to give), `F + 2 → F + 5` is none, and /// `F + 2 → F − 1` hands that one step back. Shrinking mirrors growing by construction. static func resizeWindowDelta( from oldUnits: Int, to newUnits: Int, fittingUnits: Int, step: CGFloat ) -> CGFloat { CGFloat(min(newUnits, fittingUnits) - min(oldUnits, fittingUnits)) * step } }