A lane folds to a fixed slim vertical strip carrying its glyph, its card-count badge and its title turned on its side, and the strip is deliberately not part of the window's division: the expanded lanes' units divide what is left once each folded strip's fixed width has come off the top, so folding a lane is a re-divide trigger of the Show/Hide Trash family — the window never moves and the siblings grow into what the lane gave up. The state is a first-class lane frontmatter key, `collapsed: true`, and document state exactly as `width` is: the files are the board, so an agent folds a lane by writing one key. Absent means expanded, expanding removes the key rather than writing `false` (the remove-at-default family beside a one-unit `width`, the empty rename's `title` and the None well's `background`), and the lane's `width` rides along untouched so expanding restores the lane the user had. The read is `width`'s leniency one type over — a boolean scalar or a quoted boolean word reads as itself, everything else has no reading at all and renders as expanded, bytes preserved either way. Toggling is the header's always-visible collapse chevron, the lane context menu's single Collapse Lane / Expand Lane row, and a plain click anywhere on the strip; a modified click on the strip stays the ordinary selection grammar, so a folded lane is still selectable by pointer. The title reads bottom-up and is justified to the top of the room below the strip's chrome (owner ruling 2026-08-08), truncating against the strip's own height. While folded the lane draws no cards at all, which is what makes every exclusion true by construction rather than by a guard per gesture: no card face means no marquee target and no navigation frame, and no registered grid means the masonry's drop zones have nothing to resolve against. What did need code is the half that names absolute destinations — the option-arrow jumps and the arrow seed scan past a folded lane, the lane domain's down-arrow is inert on one, and New Card skips it (a selection inside one falls through to the last-active lane, the stale selection's rule). A drop on the strip appends at the lane's end, cards and Finder files alike, with an accent edge standing in for the shadow the strip has no masonry to open; there is no hover-to-auto- expand yet. Lane reorder works on the strip, and a dragged folded lane carries its fold, so its shadow and its replica are the strip rather than its units. The write is `writeLaneWidths` clause for clause — one `updateIndex` bracket, the same stamp behaviour, the same three do-nothing paths — with two new `WriteOperation` cases and two new undo verbs rather than one of each, because a banner or an Edit-menu row that said "resize" after Collapse Lane would name a control the user never touched. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
415 lines
22 KiB
Swift
415 lines
22 KiB
Swift
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<CGFloat>], 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<CGFloat>],
|
||
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<CGFloat>] {
|
||
laneExtents(
|
||
widths: unitCounts.map { LaneLayoutMath.slotWidth(units: $0, standard: standard, gap: gap) },
|
||
gap: gap)
|
||
}
|
||
|
||
/// The same extents over **drawn** widths, which is what a strip holding collapsed lanes has: a
|
||
/// slim strip's width is a fixed figure and not a multiple of the standard (03-board-ui.md § Lane ▸
|
||
/// Collapsed lanes; `LaneLayoutMath.drawnWidths`).
|
||
///
|
||
/// The primitive, with the unit-count version above as its wrapper — one walk, so a folded lane can
|
||
/// only ever move a zone, never change how zones are built.
|
||
static func laneExtents(widths: [CGFloat], gap: CGFloat) -> [ClosedRange<CGFloat>] {
|
||
var extents: [ClosedRange<CGFloat>] = []
|
||
var left = gap
|
||
for width in widths {
|
||
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 {
|
||
laneRunSpan(
|
||
widths: unitCounts.map { LaneLayoutMath.slotWidth(units: $0, standard: standard, gap: gap) },
|
||
gap: gap)
|
||
}
|
||
|
||
/// The same span over **drawn** widths — a dragged run that includes a collapsed lane occupies that
|
||
/// lane's slim strip, not the slot its `width` key would have bought (see `laneExtents(widths:)`).
|
||
static func laneRunSpan(widths: [CGFloat], gap: CGFloat) -> CGFloat {
|
||
guard !widths.isEmpty else { return 0 }
|
||
return widths.reduce(0, +) + gap * CGFloat(widths.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? {
|
||
laneSlot(
|
||
cursorX: cursorX,
|
||
restingWidths: restingUnits.map { LaneLayoutMath.slotWidth(units: $0, standard: standard, gap: gap) },
|
||
draggedWidths: draggedUnits.map { LaneLayoutMath.slotWidth(units: $0, standard: standard, gap: gap) },
|
||
gap: gap,
|
||
current: current
|
||
)
|
||
}
|
||
|
||
/// The same answer from **drawn** widths — what the board actually asks, since a collapsed lane's
|
||
/// slim strip is neither a unit count nor a multiple of the standard (03-board-ui.md § Lane ▸
|
||
/// Collapsed lanes).
|
||
///
|
||
/// A folded lane is an ordinary member of both lists: it occupies one zone in the resting strip like
|
||
/// any other lane (so a reorder drags *across* it in one narrow zone rather than several), and a
|
||
/// folded lane being dragged contributes its strip's width to the run's span cap, so the trigger
|
||
/// region matches the footprint the drop will actually produce.
|
||
static func laneSlot(
|
||
cursorX: CGFloat,
|
||
restingWidths: [CGFloat],
|
||
draggedWidths: [CGFloat],
|
||
gap: CGFloat,
|
||
current: Int?
|
||
) -> Int? {
|
||
slot(
|
||
cursor: cursorX,
|
||
extents: laneExtents(widths: restingWidths, gap: gap),
|
||
gap: gap,
|
||
draggedSpan: laneRunSpan(widths: draggedWidths, gap: gap),
|
||
current: current
|
||
)
|
||
}
|
||
|
||
// MARK: - The card masonry
|
||
|
||
/// Which interior column `x` falls in — `0..<placement.columnCount`, clamped, so the lane's
|
||
/// padding and the region above its grid target the nearest column rather than nothing.
|
||
///
|
||
/// The bands tile: column `c` plus half a spacing on each side. `currentColumn` breaks an
|
||
/// exact-boundary tie exactly as `containingSlot` does in 1D.
|
||
static func columnIndex(atX x: CGFloat, placement: MasonryPlacement, currentColumn: Int?) -> 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 the *contiguous* logical range `[start(c), start(c + 1))`
|
||
/// (`MasonryPlacement.columnStart(_:itemCount:)`); 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,
|
||
/// as is the region above its first.
|
||
/// 3. **Logical index** — column `c`, row `r` is position `start(c) + r`, and no clamp is
|
||
/// needed: `r` never exceeds the column's card count, so the answer never leaves
|
||
/// `0...heights.count`. A column's tail maps to `start(c + 1)` — the head of the next column,
|
||
/// a genuine mid-list position — so "below this column" proposes landing there rather than
|
||
/// appending. Only the *last* column's tail is the end slot, which is the honest reading now
|
||
/// that the columns are read in order.
|
||
///
|
||
/// - 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 }
|
||
|
||
// The proposal's own column, consulted only to settle an exact band tie. The end slot is
|
||
// the last column's tail, so it names that column rather than no column at all.
|
||
let currentColumn: Int? = {
|
||
guard let current, (0...count).contains(current) else { return nil }
|
||
return placement.column(of: current, itemCount: count)
|
||
}()
|
||
let column = columnIndex(atX: cursor.x, placement: placement, currentColumn: currentColumn)
|
||
|
||
let frames = placement.frames(heights: heights)
|
||
let start = placement.columnStart(column, itemCount: count)
|
||
let end = placement.columnStart(column + 1, itemCount: count)
|
||
let extents = (start..<end).map { frames[$0].minY...frames[$0].maxY }
|
||
|
||
// The row this column would hold the current proposal at. `start...end` is exactly the set
|
||
// of logical positions this column's rows name — its own cards' positions plus its tail —
|
||
// so a proposal outside it belongs to another column, where a hold would be meaningless and
|
||
// the answer is nothing.
|
||
let currentRow: Int? = {
|
||
guard let current, (start...end).contains(current) else { return nil }
|
||
return current - start
|
||
}()
|
||
|
||
guard let row = slot(cursor: cursor.y, extents: extents, gap: placement.spacing,
|
||
draggedSpan: draggedHeight, current: currentRow)
|
||
else { return nil }
|
||
|
||
return placement.index(column: column, row: row, itemCount: 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<T: Equatable>(_ 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)
|
||
}
|
||
}
|