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], 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], 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 `LaneLayoutMath.laneIndex` 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] { var extents: [ClosedRange] = [] 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.. 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(_ 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 } } // MARK: - A Finder file drag's zones /// Where an external **Finder file** drag resolves inside one lane, as pure arithmetic /// (`FileDropZoneTests`) — the three answers a lane's own geometry gives it. /// /// **Drops are positional everywhere** (04-interactions.md ▸ Drag and drop, settled 2026-07-28): /// "created cards land at the drop position — resolved through the same card-grid zones an ordinary /// card drag uses, shadow included", and append-at-bottom stays the creation *trio*'s rule (⌘N, /// Return, a double click on empty space), not the drop's. So the create landing is /// `DropSlotMath.cardSlot` and nothing else: the very function a card drag proposes through, with the /// incoming run's **nominal** footprint standing in for the frozen height a card drag freezes at /// pickup — the cards being proposed do not exist yet to have been measured, exactly as a cross-board /// arrival's do not. /// /// Kept out of `BoardDropContext` for `TrashDrop`'s reason: the ruling is then checkable without a /// window, and the hover and the release read one answer rather than two that can drift. enum FileDropZones { /// What the lane's geometry says the files would become. enum Landing: Equatable, Sendable { /// The cursor is over the card at this position in the lane's **logical** card order — the /// files join its `attachments/`. Attach beats create anywhere on a card's bounds. case attach(index: Int) /// One card per file, opening at this position in the logical card order. case create(index: Int) /// A dead region (`DropSlotMath.slot`'s `nil`): **hold** whatever the create slot already was. case hold } /// Resolves `cursor` against one lane, in three questions asked in this order. /// /// 1. **The lane header is the topmost position** (04-interactions.md ▸ Drag and drop, settled /// 2026-07-28: "a release on the lane header resolves to the topmost position — forgiving beats /// a dead stripe: the header's chrome roles don't collide with a file payload"). The header /// does not scroll, so its own edge is the honest boundary; the accent band and the plate's top /// padding sit above it and are its chrome, which is why the test is *at or above* rather than /// containment. It is asked **first** because a scrolled masonry can place a card's analytic /// frame behind the header stripe, and the ruling admits no exception there. /// 2. **A card under the cursor always wins** over the lane behind it. The bounds are the resting /// frames `MasonryPlacement.frames(heights:)` replays — the same reconstruction the slot zones /// are built from, never a measured frame (03-board-ui.md § Motion). Closed containment, first /// match wins, so the answer is deterministic however the frames abut. /// 3. **Otherwise the create slot**, from `DropSlotMath.cardSlot`. /// /// - Parameters: /// - cursor: the pointer, in `placement.origin`'s space. /// - headerBottom: the lane header's bottom edge in that same space, or `nil` for a lane whose /// header has not registered a frame yet — where the masonry answers alone. /// - placement: the lane's resting grid geometry. /// - heights: the lane's rendered cards' heights, in logical order. /// - nominalHeight: the height an incoming, unmeasured card is assumed to have — the span the /// create zone's cap is measured against, and the height each shadow draws at. /// - current: the create slot currently proposed for this lane, or `nil`. static func landing( cursor: CGPoint, headerBottom: CGFloat?, placement: MasonryPlacement, heights: [CGFloat], nominalHeight: CGFloat, current: Int? ) -> Landing { if let headerBottom, cursor.y <= headerBottom { return .create(index: 0) } let frames = placement.frames(heights: heights) if let index = frames.firstIndex(where: { frame in cursor.x >= frame.minX && cursor.x <= frame.maxX && cursor.y >= frame.minY && cursor.y <= frame.maxY }) { return .attach(index: index) } guard let slot = DropSlotMath.cardSlot( cursor: cursor, placement: placement, heights: heights, draggedHeight: nominalHeight, current: current ) else { return .hold } return .create(index: slot) } }