diff --git a/DRAG-REORDER.md b/DRAG-REORDER.md new file mode 100644 index 0000000..56c3396 --- /dev/null +++ b/DRAG-REORDER.md @@ -0,0 +1,183 @@ +# Drag-Reorder Model + +How dragging reorders items on a board. Ported from the pathfinder's document of the same name and rewritten for Lanework's two layouts: the **lane strip** (horizontal, mixed widths via the `width` unit multiplier) and the **card masonry** inside every lane (as many interior columns of standard width as the lane has units, each column stacking independently). The pathfinder wrote its model for columns and kept a "Generalizing to 2D" coda for the day cards could differ in size; in Lanework that day is the first one, so the coda is the present tense and lives inline. + +Implementation: `Kanban/UI/Board/DropSlotMath.swift` (pure zone math), `Kanban/UI/Board/MasonryLayout.swift` (`MasonryPlacement`, the resting grid), `Kanban/UI/Board/DragAutoScrollMath.swift` (edge autoscroll geometry), `Kanban/UI/Board/LaneLayoutMath.swift` (the strip's analytic resting layout), and the store's drop commits in `Kanban/LiveStore/BoardStore.swift` (`moveCards`, `copyCards`, `receiveCards`, `receiveLanes`, `restoreByDrag`, `receiveRestoredCards`). Tests in `KanbanTests/DropSlotMathTests.swift`, `KanbanTests/DragAutoScrollMathTests.swift` and `KanbanTests/DragWriteTests.swift`. The drag session itself — gestures, drop delegates, the travelling replica, the badge — is the second half of the same milestone and is named below wherever this document depends on it. + +## The pieces + +A drag session involves four visual actors: + +1. **Drag handle** — the affordance that starts the drag (a lane's title bar and empty space, 03-board-ui.md ▸ Lane; a card's whole face). Only the handle initiates; everything else about the session is about the item, not the handle. +2. **Drag replica** — the image travelling under the cursor. A faithful, full-size replica of the dragged item (the whole lane, not the strip of title bar that was grabbed), fanned with ghosts and a count badge for multi-drags. Its tracking is verbatim input echo and never animates; only its bracketing transitions do — the pickup lift and the release's fly-to-slot or fly-back (03-board-ui.md § Motion, settled). +3. **Shadow placeholder** — a dashed outline occupying the item's proposed landing spot in the layout. At drag start it replaces the item's original space; thereafter it marks wherever the current proposal is. A multi-drag shows **N contiguous shadows**. The drop always lands exactly where the shadows show. +4. **The reflow** — siblings animating aside to make room when the proposal moves ("pre-drop"), under the structural-voice spring keyed on the proposal and nothing broader (03-board-ui.md § Motion). + +## Resting-layout zones + +While a drag is in flight, the proposal (an insertion index) is computed geometrically against the **resting layout**: the visible siblings laid out with the dragged items removed and no placeholder inserted. Each slot `i` owns a *zone*: item `i`'s entire resting extent plus half the inter-item gap on each side. Zones tile the container with no dead space between them; before the first item and past the last item lie the outermost slots. + +The proposal is a pure function of the cursor over these fixed zones. This is deliberately **not** derived from per-item hover events, which feed back off the very reflow they cause (items move under the cursor → a different item fires → the proposal moves again) and jitter. Because the zones are reconstructed analytically rather than measured, they do not move when the placeholder does. + +**The resting layout excludes the dragged run whatever the effective operation is.** A within-board ⌥-drag copies, and the originals really do stay — but ⌥ can be pressed and released mid-drag, and a layout that re-admitted the originals on every modifier flip would flap the whole board under the cursor. The dragged items are lifted out at pickup and stay out until release; the copy's originals reappear when the write lands. This is why the drop index means the same thing for `moveCards` and `copyCards` (see **The drop commits** below). + +Two stability rules on top: + +- **Exact-boundary tie** — a cursor resting on a boundary pixel keeps the current proposal when it adjoins that boundary; the shadow can never oscillate on a single pixel. +- **Own-slot pickup** — zones derive from the resting layout, so picking an item up over its original spot proposes its own slot: a no-op, no reflow, and the store's commits refuse to write for it. + +## The lane strip's resting layout is arithmetic + +The strip has no scroller and no measured frames worth reading: every lane is always on screen because the window's width divides across the lanes' width units (03-board-ui.md § Layout — full visibility). So the resting layout is a closed-form expression of `standardWidth(stripWidth:totalUnits:gap:)`, `slotWidth(units:standard:gap:)` and the unit counts of the visible lanes *minus the dragged run* — `LaneLayoutMath`'s own arithmetic, reused rather than restated (`DropSlotMath.laneExtents`). The first slot starts at `gap`, because the strip's outer margin is one gap wide, exactly as `LaneReorderMath.centre` already reads it. + +`standard` is **not** recomputed with the dragged lanes removed. It is a function of the board's unit total, and a lane in flight is still a lane on the board — the shadow occupies its units. Recomputing would re-divide the whole strip at pickup and again at release, which is the "motion feeds back into logic" failure this model exists to avoid. For a cross-board arrival the destination board's own `standard` is the one that counts, and the arriving run is measured in the destination's units. + +The trash quasi-lane consumes one unit while shown and is never a landing spot for anything (04-interactions.md ▸ The trash: no move or paste ever targets the trash). It is excluded from the strip's slot list, and the terminal slot's uncapped reach past the last real lane is clamped by the session rather than by the arithmetic. + +## Span-capped trigger regions (mixed sizes) + +When items can differ in size, "hovering anywhere over an item" is the wrong trigger. Dropping a 1× lane before a 3× lane puts the 1× lane at the 3× lane's *leading edge* — so a cursor over the 3× lane's far side is nowhere near where the dragged lane would actually land, and reflowing there feels wrong and twitchy. 04-interactions.md ▸ Drag and drop asks for exactly this: "no reflow until the cursor reaches where the dragged lane would actually land". + +The rule: slot `i` triggers only while the cursor is over the span the dragged run would **actually occupy** once dropped there — + +``` +trigger(i) = [ leading(i) − gap/2, leading(i) + draggedSpan + gap/2 ] +``` + +where `leading(i)` is item `i`'s resting leading edge and `draggedSpan` is the total extent of the dragged run (sum of the dragged items' slot widths plus the gaps between them — `DropSlotMath.laneRunSpan`). Note this is exactly where the shadows will sit if the proposal is accepted: the trigger region *is* the shadow run's future footprint. + +The remainder of a wider item's zone — beyond the cap — is a **dead region**. + +Two slots are never capped, because nothing beyond them could be confused for a different target: + +- the **end slot** (past the last item): appending is the only reading; +- the region **before the first item**: slot 0 is the only reading (it falls out of the arithmetic — the cap only ever truncates a zone's far side). + +If the dragged run is at least as large as the item whose zone it crosses, the cap covers the whole zone and behavior is identical to the uncapped model. + +## Hysteresis + +A dead region changes nothing: the current proposal — and therefore the shadow — **holds** until the cursor enters another slot's live trigger region. Combined with the tiling zones (a zone is left only by entering another) this gives the drag its hysteresis: the shadow never bounces while the cursor drifts through ambiguous territory, it only moves when a genuinely new landing spot is reached. + +The math says this by **returning `nil`**, not by returning the caller's own value back to it: `nil` is "hold", and a session that has no proposal yet still has none. That is the difference between the hold and the fresh-entry case below, and it is why the API is `Int?`. + +Edge case: if a dead region is hovered with **no valid prior proposal** — a fresh cross-board entry, or the first sample after a reload invalidated the last proposal — the containing slot is proposed anyway. A drag in flight over a live target must always have *some* landing spot. + +## The card masonry (2D) + +A lane lays its cards out with `MasonryLayout`: card `i` goes to interior column `i % columnCount`, and each column stacks its cards top-aligned and independently, with no row alignment across columns (03-board-ui.md § Lane). Card widths are uniform — the column width — and heights vary, so the grid is genuinely two-dimensional and the span-cap applies on the vertical axis. + +The resting grid is **re-run, not measured**: `MasonryPlacement.frames(heights:)` replays the same placement over the lane's rendered cards minus the dragged ones, from `(columnCount, columnWidth, spacing, heights)`. `MasonryLayout` itself places subviews through that one function, so the resting grid a drag reasons about and the grid SwiftUI draws cannot drift apart. + +**Heights are frozen at drag start** and passed in, never measured mid-flight — the animation-proof rule below, and the reason a lane whose cards are reflowing under a ~0.18s spring still resolves stable proposals. + +Cursor → proposal, in three steps (`DropSlotMath.cardSlot`): + +1. **Column.** The interior columns' x-bands tile the lane's card area — column `c` plus half a spacing on each side — and the cursor's band picks `c`. Outside the outermost bands the cursor clamps inward, so the lane's padding and its header target the nearest column rather than nothing. Exact-boundary ties keep the current proposal's column, as in 1D. +2. **Row.** Column `c`'s cards are logical indices `c, c + C, c + 2C, …`; their vertical extents feed the *same* 1D span-capped machinery the strip uses, with `draggedSpan` = the **first dragged card's frozen height** (the run's footprint at the landing spot; the remaining shadows stack below it, and the trigger rect that matters is the one the cursor is over). Dead regions hold, the tail slot below the column's last card is uncapped, and the region above the first card is uncapped. +3. **Logical index.** Column `c`, row `r` is logical position `r · C + c`, clamped to the card count. The clamp is the only place the arithmetic bends: 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. + +The insertion index is therefore always a position in the lane's **logical card order**, which is what the store writes and what 10-accessibility.md's logical-order rule requires. A consequence worth stating out loud: because assignment to columns is round-robin, inserting at index `k` shifts every later card one position and therefore *across* columns. That reflow is the point — `MasonryLayout` is a `Layout` over a single `ForEach` precisely so those moves animate as positional slides rather than as remove/insert blinks. + +Everything else carries over unchanged: resting-layout reconstruction, boundary ties, own-slot no-op, uncapped terminal slots. + +## Multi-drag + +Dragging any member of a multi-selection drags the whole selection (04-interactions.md ▸ Drag and drop). Three rules follow, and the first two are the whole of what the math has to know: + +- **One proposal for the whole run.** A multi-drag proposes a single insertion index and inserts contiguously there. There is no per-item targeting and no interleaving. +- **The run's span is the run's span.** `draggedSpan` sums the dragged items' extents plus the gaps between them, so a two-lane drag has to travel twice as far before a wider neighbour's slot triggers. In the masonry the vertical cap uses the first dragged card's frozen height, since that is the shadow the cursor is over. +- **Preserved relative order** is *flatten order* — "lane `order` first, then card `order` (a cross-lane selection flattens left-to-right, top-to-bottom)" — the same order the ⌘N target rule and paste anchor on. `SelectionGrammar.liveCards` is its single definition; the drop commits sort their members through it rather than through the `Set`'s iteration order, which has none. + +N contiguous shadows are rendered by the session; the *index* is all this document's arithmetic produces. + +## Cross-board sessions and the locality model + +**Locality picks the default — the Finder volume model** (04-interactions.md ▸ Drag and drop, settled). Within a board a drag is a **move**: rearranging. Between boards it is a **copy**: transferring, with the system copy badge showing over the foreign board. **⌥ always forces copy** and **⌘ always forces move** — Finder's exact modifier grammar — and each is a no-op where its behavior is already the default. The badge tracks the effective operation live as the cursor crosses a board boundary, which means the operation is a function of (source board, board under the cursor, modifiers) sampled every frame, not a decision taken at pickup. + +Two carve-outs: + +- **Lane drags never copy within their board.** ⌥ is simply ignored there: the drag stays a clean reorder and the badge never shows copy. The within-board lane duplicate exists, but its home is the clipboard (04-interactions.md ▸ Clipboard, Lane paste) — the usual shape, where the keyboard path is canonical and the drag is the enhancement. +- **A lane copy strips tombstoned cards**; a lane **move** carries them whole, and they land in the destination's trash by rendering. Copies transfer content, and trash isn't content (09-templates.md's instantiation precedent). + +Geometry does not change across the boundary. The destination board's own resting layout answers the proposal, in the destination's own `standard` and gap; the arriving run's span is its unit counts measured against the destination's standard. What changes is only which commit runs and on which store — see below. + +## The drop commits + +The commit is the store's, and it is one `performWrite` bracket per gesture whatever the set's size: one app-mediated reload, and (on git boards) one commit rather than N. Every one of them takes an index counted **against the destination's rendered items with the dragged run removed** — the resting layout's own convention, so the number the geometry produced is the number the writer consumes, unrewritten. + +| Gesture | Store method | Writer | +| --- | --- | --- | +| Within-board card drag | `moveCards(_:toLane:at:)` | `moveItem` per card — same parent degrades to a rank rewrite | +| Within-board ⌥-drag | `copyCards(_:toLane:at:)` | `copyItem` per card, `.fork` stamps | +| Within-board lane drag | `moveLane(_:toIndex:)` | `moveItem`, same-parent reorder | +| Cross-board cards | `receiveCards(_:operation:toLane:at:)` on the **destination** store | `copyItem` / `moveItem` | +| Cross-board lanes | `receiveLanes(_:operation:at:)` on the **destination** store | `copyItem` + tombstone strip / `moveItem` | +| Trash → live lane, same board | `restoreByDrag(cardID:intoLane:at:)` | `restoreItem` then `moveItem` | +| Trash → another board | `receiveRestoredCards(_:operation:toLane:at:)` | `copyItem`/`moveItem` then `restoreItem` | + +Three properties of that table are load-bearing: + +- **Ranks are inserted, never permuted.** A drop writes only the dragged items' `order` — the siblings' files are not touched, so `modified` (and a git commit) stays honest about what actually moved. `Ranks.insertionRanks(amongVisible:at:count:)` produces the N ranks the contiguous run needs; `nil` from it is the renumber trigger, exactly as an exhausted midpoint is everywhere else, and the fallback compacts the destination and places again (`moveLane`'s and `sortSelection`'s pattern). +- **A copy's ranks are computed against the destination's *full* rendered set**, because the originals stay and a rank chosen in the gap the lifted originals vacated would collide with them. A move's are computed against the set the moving members vacate. One expression covers both: the ranks are placed among the rendered cards minus whatever will actually leave. +- **Cross-board writes are executed by the destination store**, inside *its* bracket. The source board's tree changes outside its own store's bracket, which is correct and needs no coordination: the source store's watcher sees a foreign change and reloads, which is what a foreign change is. + +Identity follows 01-storage-format.md exactly. A copy mints fresh UUIDs at every level and keeps `created` (a copy is a fork). A move keeps the UUID; only the **import boundary** remints, per folder, at the finest grain — a lane arriving with one colliding card is still a lane move with one reminted card. + +## The mid-drag re-grounding trio + +**A foreign reload mid-drag re-grounds the drag, never corrupts the drop** (04-interactions.md ▸ Drag and drop, settled — a two-second drag racing agent edits is the designed concurrency). Three rules compose, and each one is a property of something already in this document: + +1. **Geometry re-derives.** The only inputs frozen at drag start are the *dragged items'* sizes and nothing else; the resting zones are recomputed against each new snapshot. A foreign lane add or tombstone re-divides the strip, the zones move with it, and the next proposal targets the board as it now is. Nothing is cached across a reload because nothing needs to be. +2. **Proposals re-validate by liveness.** A proposal whose target lane was tombstoned or vanished in the reload is invalidated — tombstoned lanes are never drop targets — the shadow withdraws, and no proposal stands until the pointer reaches a live target. **Release with no valid proposal cancels**: items return, nothing is written, and a card is never filed under a `deleted:` parent. The store's commits enforce the same rule independently (a destination lane that is gone or tombstoned is a silent no-op), so the gesture and the write cannot disagree. +3. **An emptied drag cancels itself.** Drag membership is a UUID set that vanished items leave silently (02-architecture.md); when the *last* dragged item leaves it, the replica dissolves and release is a no-op. Partial vanishing drops the survivors, matching the pending-cut precedent. + +## The committed-overlay hold + +At release the write goes to disk and the *snapshot does not change*. The watcher's bracket closes, a reload runs, and only then does the board show the new order — one-way flow, deliberately (02-architecture.md). In between, for one round trip, the snapshot still describes the pre-drop arrangement. + +That gap is what makes "the replica flies to its slot" hard to reconcile with the one-way flow: the slot it should fly to is a position that does not exist yet, and dropping the drag state at release would snap every sibling back to the pre-drop layout for a frame before the reload lands. + +The resolution is the **committed-overlay hold**, and it is the new-card placeholder's `awaitingArrival` precedent applied to the drag: at release the session flips from *proposing* to *committed*, keeps rendering the arrangement it was showing, and stands until the app-mediated reload that carries the write arrives — then hands off and dissolves. The hand-off condition is the same shape as the placeholder's: the overlay watches the snapshot for the state it is standing in for, and discards itself the moment the snapshot has it, because holding a moment longer would draw the arrangement twice. + +Like the placeholder, it is store-transient overlay state and a **named exception** to the one-way flow rather than a hole in it: it renders nothing that is not already on disk or already refused, and every failure path — a write that throws, a reload that fails, a session emptied mid-flight — dissolves it and lets the snapshot be the authority again. The banner says what went wrong; the board shows what is true. + +This is the drag session's mechanism, not the math's — it belongs to the same milestone's second half. + +## Animation-proof inputs (implementation constraint) + +**Motion never feeds back into logic** (03-board-ui.md § Motion, a hard constraint, inherited from the pathfinder's rule of the same name). Every proposal change animates a reflow (~0.18s). During that window, anything *measured* is mid-flight: item frames, the placeholder's frame, and even the drop location reported by the system (it is expressed in the drop target's space, and that view may itself be moving). Retargeting from measured values while the board animates produces garbage zones and a proposal that thrashes — the shadow chases the cursor and all hysteresis is lost. Three rules follow: + +- **Compute resting zones analytically, never from measured frames.** The strip's layout is a closed form over `(stripWidth, gap, unit counts)`; the masonry's is a closed form over `(columnCount, columnWidth, spacing, frozen heights)`. Both are stable no matter what is animating. +- **Read the cursor from the physical mouse** (`NSEvent.mouseLocation`, converted through the window), not from the drop callback's location. This is also what `LaneResizeSession` and `MarqueeSession` already do. +- **Freeze the dragged items' sizes at drag start.** The pickup transition fires geometry updates while the dragged item lifts and scales; its lingering "last measured frame" is a few per cent off, which would mis-size the shadow and the span-cap. + +One lifecycle trap in the same family, recorded because the pathfinder paid for it: a finished session's phase events can be delivered *after the user has already started the next drag*, and a naive cleanup handler wipes the new session's state (no shadow, drop dead). Cleanup on session-phase events must be gated on the physical button being up; a mouse-polling watchdog remains the guaranteed termination path. + +## Single-target dispatch (implementation constraint) + +SwiftUI/macOS delivers a drag session to the **deepest drop region under the cursor — with no fall-through**, not even when that target's declared content types don't match the session's payload. A region whose topmost target only understands one drag type is therefore a *dead zone* for the other type: no hover callbacks, and a release there snaps back instead of committing. + +Consequence: every drop delegate must accept **all session types** — card, lane, and external Finder file drags (04-interactions.md ▸ Drag and drop: files onto a card become attachments, files onto lane empty space become cards) — and route internally. Without file support at every fall-through layer, the same dead-region hit-testing bug would strand a file session: it would fall through to a target that only declares the board's own types, get no hover callbacks, and refuse the drop outright, with no highlight and no snapback to explain why. + +The lane body's delegate resolves card sessions against its masonry zones, forwards lane sessions (cursor converted to strip space) to the strip's logic, and resolves file sessions against the same masonry zones; the strip delegate (gaps, margins, placeholder regions) retargets lane sessions, retargets card and file sessions against an analytically reconstructed per-lane grid — the safety net for a lane whose own drop region goes dead — and commits the current proposal on release: the drop always lands where the shadows show. Shadow placeholders are hit-transparent, so the strip target stays live beneath them. + +## Edge autoscroll + +A lane's cards live in a scroll view, so a lane taller than its viewport has landing spots below the fold. Nothing in the model above can reach them — the proposal is a function of the cursor over the *visible* resting layout — so a card session hovering near either end of a lane's scroll area scrolls it, continuously, until the pointer leaves the band or the drag ends. Implementation: `Kanban/UI/Board/DragAutoScrollMath.swift`, tests in `KanbanTests/DragAutoScrollMathTests.swift`. + +The geometry is a pure function of viewport-local coordinates: each end of the visible extent owns a 56pt **activation band**, and a pointer inside one scrolls that way at a speed ramping linearly from 90pt/s at the band's inner edge to 800pt/s at (and beyond) the viewport's own edge. Outside both bands the velocity is exactly zero, so a drag crossing a lane's middle never scrolls it. The floor at the band boundary is deliberate — entering a band should produce visible motion, not an imperceptible crawl. + +The pointer may also sit outside the visible area and still drive it: generously above and below (a lane's header and the strip's padding are still "this lane"), but only ~12pt sideways, so a drag over the neighbouring lane never scrolls this one. + +Three constraints shape the driver, which is the session's half of the work: + +- **The pointer is the physical mouse**, partly for the general reason above, but mostly because drop callbacks only arrive while the mouse *moves*, and holding still against an edge is exactly the gesture that must keep scrolling. A ticking task plus `NSEvent.mouseLocation` needs no events at all. +- **Every scroll step re-resolves the proposal.** The cursor is stationary in the lane's space while the *content* moves under it, so without this the shadow would freeze at whatever slot the last mouse movement proposed and the drop would land there. The lane's drop delegate and the autoscroll driver must go through one shared retarget, so they can never disagree. +- **Termination is structural**, like the rest of the session lifecycle: the driver is a `.task(id:)` keyed on the session, so it is cancelled the moment the session ends — and the watchdog guarantees that flag clears no matter how the drag finished. + +The board strip itself has nothing to autoscroll: every lane is always visible (the window width divides across the lanes' width units) and the strip fills the window height, so there is no board-level scroller in either axis. The geometry above is axis-agnostic and would serve one unchanged if that ever changes. + +## Adjacent interaction: the lane resize drag (not a drag session) + +Dragging a lane's trailing edge resizes it between whole unit counts — see `LaneResizeSession.swift` and `LaneLayoutMath`. It deliberately lives OUTSIDE the drag-session machinery above: the handle is a plain `DragGesture`, carries no drop target, and refuses to start while a card/lane session is in flight. Its layout trick inverts this document's premise: instead of reflowing siblings around a shadow, the session freezes the strip's standard width and resizes the *window* by one standard-plus-gap per snap tick, so every other lane keeps its exact pixels and the release settles the dragged lane into a slot that's already in place. Snapping is asymmetric ("shadow leads"): tick up the instant the live edge clears the inter-lane gap; tick down only after retreating 10pt back into it — the 10pt re-entry band is the only hysteresis, cousin to the dead-region hold above. diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index c029ba9..db4b76c 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -72,6 +72,25 @@ public enum BoardStoreWriteRefusal: Error, Sendable, Equatable, CustomStringConv } } +/// What a cross-board drop is doing to the items it carries — the **effective** operation the +/// locality model resolved (04-interactions.md ▸ Drag and drop, settled). +/// +/// Not a modifier and not a direction: by the time a store sees one of these the Finder volume +/// model has already been applied — within a board a drag is a move, between boards a copy, ⌥ +/// forces copy and ⌘ forces move, each a no-op where it is already the default — and the badge the +/// user was looking at said exactly this. Two cases and no `.none`: a drag with no valid proposal +/// never reaches a commit at all (▸ Drag and drop, rule 2: "release with no valid proposal +/// cancels"). +public enum TransferOperation: Sendable, Equatable { + /// Fresh-GUID duplicates land at the drop, originals stay, `created` is kept — a copy is a + /// fork (01-storage-format.md). + case copy + + /// A real filesystem move: identity travels, and only the import boundary remints, per folder + /// (01-storage-format.md's per-folder degradation). + case move +} + // MARK: - BoardStore /// The per-board hub: one live snapshot, one reload pipeline, and the read-side conditions the @@ -1101,6 +1120,332 @@ public final class BoardStore { } } + // MARK: - Drag & drop commits + + // The writes a released drag performs (04-interactions.md ▸ Drag and drop; the geometry that + // produces their `index` is DRAG-REORDER.md's, implemented in `DropSlotMath`). + // + // **One `performWrite` bracket per gesture**, whatever the set's size — the style batch's and + // the tombstone batch's rule, for their reason: one gesture, one app-mediated reload, one commit + // on git boards. + // + // **`index` always means the same thing**: a position among the destination's *rendered* items + // counted with the dragged run already removed — the resting layout's own convention, so the + // number the geometry produced is the number these methods consume, unrewritten. Every one of + // them clamps it rather than trusting it: a proposal computed against a snapshot one reload old + // must not trap. + // + // **Ranks are inserted, never permuted.** A drop rewrites only the dragged items' `order`, so + // the siblings' files — and `modified`, and a git commit — stay honest about what actually + // moved. That is the one place these differ from `sortSelection`, which permutes because its + // gesture is a permutation. `Ranks.insertionRanks` answering `nil` is the renumber trigger, and + // the fallback is `moveLane`'s: compact the destination, then place against the fresh ladder. + // + // **Silent no-ops throughout**, all of them the reload being the authority rather than the + // gesture: a destination lane that is gone or tombstoned (04's "a card is never filed under a + // `deleted:` parent"), a dragged set emptied by a foreign reload, and a drop that lands exactly + // where everything already is (a drag that ends where it started must not stamp `modified` or + // mint a commit — the resize drag's rule). + + /// One member of a dragged card set, resolved against the snapshot: which lane holds it *now*. + private struct DraggedCard { + let id: ItemID + let laneID: ItemID + } + + /// `ids` narrowed to live cards under live lanes and sorted into **flatten order** — "lane + /// `order` first, then card `order`" (`SelectionGrammar.liveCards`), which is what "drop inserts + /// contiguously in preserved relative order" means and the only order a `Set` cannot supply. + /// + /// Members that vanished or flipped liveness since the drag began are simply absent: drag + /// membership is a UUID set that vanished items leave silently (02-architecture.md), and "partial + /// vanishing drops the survivors" is the design's own wording. + private func draggedCards(_ ids: Set) -> [DraggedCard] { + var lanes: [ItemID: ItemID] = [:] + for lane in snapshot.lanes where !lane.isDeleted { + for card in lane.cards where !card.isDeleted { + lanes[card.id] = lane.id + } + } + return SelectionGrammar.liveCards(in: snapshot) + .filter { ids.contains($0) } + .compactMap { id in lanes[id].map { DraggedCard(id: id, laneID: $0) } } + } + + /// The within-board card drop: `ids` land contiguously at logical position `index` among + /// `laneID`'s rendered cards, in flatten order. + /// + /// **Uniformly `moveItem`, cross-lane members and same-lane ones alike.** A member already in + /// the destination takes the writer's same-parent degenerate path, which rewrites exactly one + /// file — its `order` — and never touches the filesystem; a member arriving from another lane + /// moves its folder and carries the same explicit rank. That is `moveItem`'s own promise ("a + /// drop that lands back in its own lane is the same gesture as one that lands elsewhere"), and + /// leaning on it is what keeps this method from growing two branches that could disagree about + /// ordering. + /// + /// The selection is deliberately untouched: every id survives the move, and the cards the user + /// is dragging should stay the cards the user is dragging. + public func moveCards(_ ids: Set, toLane laneID: ItemID, at index: Int) { + guard let destination = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) else { return } + let members = draggedCards(ids) + guard !members.isEmpty else { return } + + let rendered = destination.cards.filter { !$0.isDeleted } + let memberIDs = members.map(\.id) + let remaining = rendered.filter { !ids.contains($0.id) } + let target = min(max(0, index), remaining.count) + + // The no-op guard, stated as the arrangement rather than as a special case: if the lane + // would render exactly what it renders now, nothing moved. A member sitting in another lane + // makes the two lists differ by construction, so this covers the cross-lane case too. + guard DropSlotMath.applied(rendered.map(\.id), moving: memberIDs, to: target) != rendered.map(\.id) + else { return } + + let root = rootURL + let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true) + + try? performWrite { () throws(BoardWriteError) -> Void in + var ranks = Ranks.insertionRanks(amongVisible: remaining.map(\.order), at: target, count: members.count) + if ranks == nil { + // Compact and place again. The renumber assigns in display order over the lane's + // *live* cards, so the compacted ladder lines up one-for-one with `rendered`; the + // members already in this lane are dropped from it before the neighbours are + // consulted, exactly as `moveLane` drops the dragged lane's own rung. + try BoardWriter.renumberVisibleChildren(of: laneFolder) + let compacted = zip(rendered, Ranks.renumbered(count: rendered.count)) + .filter { !ids.contains($0.0.id) } + .map(\.1) + ranks = Ranks.insertionRanks(amongVisible: compacted, at: target, count: members.count) + } + guard let ranks else { return } + + for (member, rank) in zip(members, ranks) { + let folder = root + .appendingPathComponent(member.laneID.rawValue, isDirectory: true) + .appendingPathComponent(member.id.rawValue, isDirectory: true) + _ = try BoardWriter.moveItem( + at: folder, + toParent: laneFolder, + sourceBoardRoot: root, + destinationBoardRoot: root, + order: rank + ) + } + } + } + + /// The within-board ⌥-drag: fresh-GUID duplicates of `ids` land contiguously at `index` among + /// `laneID`'s rendered cards, **originals untouched** (04-interactions.md ▸ Drag and drop: + /// "originals stay, cursor shows the copy badge, fresh-GUID duplicates land at the drop"). + /// `created` survives because a copy is a fork — `CopyStamps.fork`, the same stamps paste uses. + /// + /// **The ranks are placed among the lane's *full* rendered set**, not among the set with the + /// dragged members removed — the one place a copy's arithmetic differs from a move's. The + /// originals are lifted out of the layout for the duration of the drag whatever the effective + /// operation is (⌥ can be pressed and released mid-drag; a layout that re-admitted them on every + /// flip would flap the whole board), but they are still *on disk* holding their ranks, and a + /// rank chosen in the gap they appear to have vacated would collide with them the instant they + /// reappear. So the drop's index is mapped through to the neighbour it names — the card the run + /// lands in front of — and the rank is taken there. + public func copyCards(_ ids: Set, toLane laneID: ItemID, at index: Int) { + guard let destination = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) else { return } + let members = draggedCards(ids) + guard !members.isEmpty else { return } + + let rendered = destination.cards.filter { !$0.isDeleted } + let remaining = rendered.filter { !ids.contains($0.id) } + let target = min(max(0, index), remaining.count) + // The resting-layout index, re-read against the layout the originals are still part of. + let placement = target < remaining.count + ? (rendered.firstIndex { $0.id == remaining[target].id } ?? rendered.count) + : rendered.count + + let root = rootURL + let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true) + + try? performWrite { () throws(BoardWriteError) -> Void in + var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: placement, count: members.count) + if ranks == nil { + try BoardWriter.renumberVisibleChildren(of: laneFolder) + ranks = Ranks.insertionRanks( + amongVisible: Ranks.renumbered(count: rendered.count), + at: placement, + count: members.count + ) + } + guard let ranks else { return } + + for (member, rank) in zip(members, ranks) { + let folder = root + .appendingPathComponent(member.laneID.rawValue, isDirectory: true) + .appendingPathComponent(member.id.rawValue, isDirectory: true) + _ = try BoardWriter.copyItem(at: folder, toParent: laneFolder, order: rank, stamps: .fork) + } + } + } + + // MARK: - Cross-board arrivals + // + // Executed by the **destination** store, inside *its* bracket, because the destination is where + // the write's effects have to round-trip. A move mutates the source board's tree outside that + // board's own bracket, which is correct and needs no coordination: the source store's watcher + // sees a foreign change and reloads, which is exactly what a foreign change is. + // + // `sources` are the items' folder URLs in the source board — both boards are open in this app, + // so both roots are already security-scoped and the payload can carry plain URLs. The source + // board root is read back off the path rather than passed alongside: 01-storage-format.md's + // fractal layout fixes the depth (`/` and `//`), so the URL + // already carries it and a second parameter could only ever disagree with the first. + + /// The board root a lane folder sits directly under. + nonisolated static func boardRoot(ofLaneFolder folder: URL) -> URL { + folder.deletingLastPathComponent() + } + + /// The board root a card folder sits two levels under. + nonisolated static func boardRoot(ofCardFolder folder: URL) -> URL { + folder.deletingLastPathComponent().deletingLastPathComponent() + } + + /// A cross-board card drop, landing contiguously at `index` among `laneID`'s rendered cards. + /// + /// - `.copy` (the default between boards) — `copyItem` per folder: fresh GUIDs throughout, + /// `created` kept, originals untouched. Copies mint by construction, so the import boundary's + /// collision question never arises. + /// - `.move` (⌘-drag) — `moveItem` per folder: identity travels, and the import boundary remints + /// **only** the folders whose UUID the destination board already holds, per folder at the + /// finest grain (01-storage-format.md's per-folder degradation, which is `moveItem`'s own + /// behaviour rather than something this method arranges). + public func receiveCards(_ sources: [URL], operation: TransferOperation, toLane laneID: ItemID, at index: Int) { + receive(sources, operation: operation, toLane: laneID, at: index, clearingTombstones: false) + } + + /// The cross-board half of drag-to-restore (04-interactions.md ▸ The trash): tombstoned rows + /// dropped on *another* board. + /// + /// Identical to `receiveCards` but for one extra write per arrival — `deleted:` is removed once + /// the folder is at its destination, so what lands is **live**, "like copying a file out of + /// Finder's Trash". The two cases the design names fall straight out of the operation: + /// + /// - `.copy` (the default) — a live copy lands here and the tombstoned original stays in the + /// source board's trash, exactly as ⌘C out of the trash behaves. + /// - `.move` (⌘-drag) — the true cross-board restore-move: the tombstone leaves the source + /// board entirely, ordinary cross-board move semantics apply, and `deleted:` is cleared at the + /// destination. + /// + /// The strip is a second `updateIndex` rather than a flag on the first because the arrival's + /// `order` is written by `copyItem`/`moveItem` before this store has a folder to point at, and + /// because `restoreItem` is already the one expression in the app for "remove the `deleted:` + /// key" — the bytes are never rewritten any other way. + public func receiveRestoredCards(_ sources: [URL], operation: TransferOperation, toLane laneID: ItemID, at index: Int) { + receive(sources, operation: operation, toLane: laneID, at: index, clearingTombstones: true) + } + + private func receive( + _ sources: [URL], + operation: TransferOperation, + toLane laneID: ItemID, + at index: Int, + clearingTombstones: Bool + ) { + guard !sources.isEmpty, + let destination = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) + else { return } + + let rendered = destination.cards.filter { !$0.isDeleted } + let target = min(max(0, index), rendered.count) + let root = rootURL + let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true) + + try? performWrite { () throws(BoardWriteError) -> Void in + var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: target, count: sources.count) + if ranks == nil { + try BoardWriter.renumberVisibleChildren(of: laneFolder) + ranks = Ranks.insertionRanks( + amongVisible: Ranks.renumbered(count: rendered.count), + at: target, + count: sources.count + ) + } + guard let ranks else { return } + + for (source, rank) in zip(sources, ranks) { + let arrived: ItemID + switch operation { + case .copy: + arrived = try BoardWriter.copyItem(at: source, toParent: laneFolder, order: rank, stamps: .fork) + case .move: + arrived = try BoardWriter.moveItem( + at: source, + toParent: laneFolder, + sourceBoardRoot: Self.boardRoot(ofCardFolder: source), + destinationBoardRoot: root, + order: rank + ).id + } + guard clearingTombstones else { continue } + try BoardWriter.restoreItem(at: laneFolder.appendingPathComponent(arrived.rawValue, isDirectory: true)) + } + } + } + + /// A cross-board lane drop, landing contiguously at `stripIndex` among this board's live lanes. + /// + /// The two operations differ in exactly one place beyond identity, and it is 04-interactions.md + /// ▸ Drag and drop's rule: + /// + /// - `.copy` — "Lanes copy cards and all", then **the copy strips tombstoned cards**: the copy + /// transfers content, and trash isn't content (09-templates.md's instantiation precedent — a + /// board isn't born with trash). The tombstoned originals stay recoverable in the source + /// board. `copyItem` offers no filter hook — it copies the tree verbatim by design, which is + /// what makes attachments and strays arrive byte-identical — so the strip is the line after + /// (`BoardWriter.stripTombstonedChildren`), pointed at a folder minted seconds earlier. + /// - `.move` — "A ⌘-drag *move* carries them whole — the folder moves as-is, and they land in + /// the destination's trash." Nothing to arrange: a move never reads below its root, so the + /// tombstones travel and the destination's trash quasi-lane renders them. + /// + /// Within-board lane reorders are `moveLane(_:toIndex:)`, and a within-board lane *copy* does + /// not exist by drag at all (⌥ is ignored on lane drags; the clipboard is that operation's one + /// home), so this method is cross-board by construction. + public func receiveLanes(_ sources: [URL], operation: TransferOperation, at stripIndex: Int) { + guard !sources.isEmpty else { return } + + let root = rootURL + let rendered = snapshot.lanes.filter { !$0.isDeleted } + let target = min(max(0, stripIndex), rendered.count) + + try? performWrite { () throws(BoardWriteError) -> Void in + var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: target, count: sources.count) + if ranks == nil { + try BoardWriter.renumberVisibleChildren(of: root) + ranks = Ranks.insertionRanks( + amongVisible: Ranks.renumbered(count: rendered.count), + at: target, + count: sources.count + ) + } + guard let ranks else { return } + + for (source, rank) in zip(sources, ranks) { + switch operation { + case .copy: + let arrived = try BoardWriter.copyItem(at: source, toParent: root, order: rank, stamps: .fork) + try BoardWriter.stripTombstonedChildren( + of: root.appendingPathComponent(arrived.rawValue, isDirectory: true) + ) + case .move: + _ = try BoardWriter.moveItem( + at: source, + toParent: root, + sourceBoardRoot: Self.boardRoot(ofLaneFolder: source), + destinationBoardRoot: root, + order: rank + ) + } + } + } + } + // MARK: - Within-lane sort /// The lane and the new card ordering one ⌥⌘↑/⌥⌘↓ press would produce, or `nil` when the press @@ -1328,52 +1673,79 @@ public final class BoardStore { clearSelection() } - /// Drag-to-restore: a tombstoned card row dropped over a live lane comes back **into that lane** - /// (03-board-ui.md § Trash, 04-interactions.md ▸ The trash). + /// Drag-to-restore: a tombstoned card row dropped over a live lane comes back **into that lane, + /// at the drop position** — `deleted:` removed and `order` set (03-board-ui.md § Trash, + /// 04-interactions.md ▸ The trash: "dropping a tombstoned card into one of its own board's lanes + /// restores it at the drop position"). /// - /// Two writes in **one bracket**, and the order is load-bearing: `restoreItem` first — the folder - /// is still where the trash row said it was — then, only when the destination differs, the move. - /// Doing it the other way round would have the second call chasing a folder the first one had - /// already relocated. + /// `index` is the drag model's own index — a position among the destination lane's rendered + /// cards, which the tombstoned card is by definition not among (DRAG-REORDER.md § The drop + /// commits). Clamped, like every other drop commit. /// - /// **Same lane is a plain Put Back**: the key is removed and nothing else is touched, so the card - /// returns at its recorded `order` rather than at the bottom. "Folder moved only if the - /// destination lane differs" is the design's own wording, and the position-perfect restore is the - /// point of the trash being a pure view. + /// **Cross-lane is two writes in one bracket, and the order is load-bearing**: `restoreItem` + /// first — the folder is still where the trash row said it was — then the move, carrying the + /// rank. Doing it the other way round would have the second call chasing a folder the first one + /// had already relocated. /// - // m5-drag: two things arrive with the drag card's `DropSlot` port. (1) The **positional** drop — - // the design restores "at the drop position", and the append below is the interim; the rank comes - // from `Ranks.insertionRank` over the destination's visible cards, exactly as `commitPlaceholder` - // computes it. (2) **Cross-board locality** — a drop on another board is a live *copy* by - // default with the tombstoned original staying put, and ⌘-drag forces the true restore-move. - // Both need the drag controller's target vocabulary; this method is deliberately within-board. + /// **Same lane never moves a folder**, so it is one write: the key removed and, only when the + /// drop actually names a different rank than the card already carries, the `order` beside it. + /// That guard is what preserves the position-perfect restore the trash's pure-view design pays + /// for — a row dropped back where its recorded order already puts it comes back *exactly* there, + /// with no rank invented for it and no neighbour disturbed. + /// + /// **Within-board only.** A drop on another board follows the locality model instead + /// (`receiveRestoredCards`): a live copy by default with the tombstoned original staying put, + /// and ⌘-drag forcing the true restore-move. /// /// Silent no-ops, all of them the reload being the authority rather than this gesture: a /// destination lane that is gone or tombstoned, a card that is not a trash row (its own flag /// unset, or its lane tombstoned so it has no row to drag), and an id that names nothing. - public func restoreByDrag(cardID: ItemID, intoLane laneID: ItemID) { - guard snapshot.lanes.contains(where: { $0.id == laneID && !$0.isDeleted }), + public func restoreByDrag(cardID: ItemID, intoLane laneID: ItemID, at index: Int) { + guard let destination = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }), let source = snapshot.lanes.first(where: { lane in !lane.isDeleted && lane.cards.contains { $0.id == cardID && $0.isDeleted } - }) + }), + let card = source.cards.first(where: { $0.id == cardID }) else { return } let root = rootURL let cardFolder = TrashModel.ItemPath(laneID: source.id, cardID: cardID).folder(under: root) - let destination = root.appendingPathComponent(laneID.rawValue, isDirectory: true) + let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true) let crossesLanes = source.id != laneID + let rendered = destination.cards.filter { !$0.isDeleted } + let target = min(max(0, index), rendered.count) + let recordedOrder = card.order + try? performWrite { () throws(BoardWriteError) -> Void in + var rank = Ranks.insertionRank(amongVisible: rendered.map(\.order), at: target) + if rank == nil { + try BoardWriter.renumberVisibleChildren(of: laneFolder) + rank = Ranks.insertionRank(amongVisible: Ranks.renumbered(count: rendered.count), at: target) + } + guard let rank else { return } + + guard crossesLanes else { + try BoardWriter.updateIndex( + inItemFolder: cardFolder, + // `.restore(title: nil)`: `updateIndex` enriches it off the document it reads. + operation: .restore(title: nil) + ) { document in + document.remove(FrontmatterKeys.deleted) + if rank != recordedOrder { + document.set(FrontmatterKeys.order, to: .double(rank)) + } + } + return + } + try BoardWriter.restoreItem(at: cardFolder) - guard crossesLanes else { return } - // `order: nil` is the Writer's own append — computed over the destination's *visible* - // siblings, which the arriving card is not yet among. _ = try BoardWriter.moveItem( at: cardFolder, - toParent: destination, + toParent: laneFolder, sourceBoardRoot: root, destinationBoardRoot: root, - order: nil + order: rank ) } } diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index 9b9a638..b9545dc 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -776,6 +776,60 @@ public enum BoardWriter: Sendable { } } + /// Physically removes every tombstoned card from a **just-copied** lane, and reports which — + /// the tail of a lane copy (04-interactions.md ▸ Drag and drop: "A lane copy **strips + /// tombstoned cards**: the copy transfers content, and trash isn't content"; the same rule + /// governs a pasted lane copy). + /// + /// **Removed, not tombstoned.** These folders were minted seconds ago by `copyItem` and were + /// never content in this board, so there is nothing here for a Put Back to recover and no + /// tombstone to leave standing — the tombstoned *originals* stay recoverable in the source + /// board, which is where the recovery story lives. A lane **move** carries them whole and never + /// calls this: the folder travels as-is and its tombstones land in the destination's trash by + /// rendering. + /// + /// **Only ever pointed at a fresh copy.** `copyItem` has no filter hook — it copies the tree + /// verbatim by design, which is what makes attachments and strays arrive byte-identical — so + /// the strip is a second step rather than a parameter, and a caller that aimed it at a lane the + /// user actually owns would be destroying their trash. Every call site in the app is the line + /// after a `copyItem` that materialized the folder. + /// + /// A child whose `index.md` is missing or unreadable is **left alone**: the liveness question + /// cannot be answered for it, and the conservative direction is to keep the folder — the same + /// leniency `copyItem` extends below its root. Liveness is read exactly as the loader reads it + /// (a present `deleted` key, malformed or not). + /// + /// The operation vocabulary is `.copy`, not `.purge`: the user pressed nothing called "delete", + /// and a failure here must say the app could not copy the lane (02-architecture.md § + /// Write-failure surfacing). + @discardableResult + public static func stripTombstonedChildren(of laneFolder: URL) throws(BoardWriteError) -> [ItemID] { + let operation = WriteOperation.copy(title: nil) + try checkIsDirectory(laneFolder, describedAs: "lane folder", operation: operation) + try checkIsUUIDShaped(laneFolder, operation: operation) + + var removed: [ItemID] = [] + for child in childCandidates(of: laneFolder) { + let indexURL = child.appendingPathComponent(BoardLoader.indexFileName) + guard FileManager.default.fileExists(atPath: indexURL.path), + let document = try? readDocument(at: indexURL, operation: operation), + !document.deleted.isMissing + else { continue } + + do { + try FileManager.default.removeItem(at: child) + } catch { + throw BoardWriteError( + operation: operation, + path: child.path, + reason: .io(message: "could not remove folder: \(error.localizedDescription)") + ) + } + removed.append(ItemID(rawValue: child.lastPathComponent)) + } + return removed + } + // MARK: - Tombstone /// Tombstones a lane or card in place: writes `deleted: ` into its own `index.md` — diff --git a/Kanban/Storage/Ranks.swift b/Kanban/Storage/Ranks.swift index e7f5ae8..df59d2d 100644 --- a/Kanban/Storage/Ranks.swift +++ b/Kanban/Storage/Ranks.swift @@ -61,6 +61,55 @@ enum Ranks: Sendable { return midpoint(between: orders[index - 1], and: orders[index]) } + /// The `count` ranks a **contiguous run** takes when it lands at display position `index` + /// among `orders` — `insertionRank(amongVisible:at:)` for a multi-drag, whose whole set inserts + /// at one spot in preserved order (04-interactions.md ▸ Drag and drop, DRAG-REORDER.md § + /// Multi-drag). + /// + /// `orders` is the visible siblings **in display order with the run itself already excluded** — + /// the resting layout's convention, the same one the geometry's index is counted in. + /// + /// The three cases mirror the single-rank twin, spread over `count` values: + /// + /// - at or before the head → `count` whole gaps *below* the first sibling, ascending; + /// - at or past the end (an empty `orders` included) → `count` whole gaps above the last; + /// - between two siblings → `count` evenly spaced points strictly inside their interval. + /// + /// **`nil` means the gap is exhausted, not that the insertion is illegal** — the interior case + /// fails when the two neighbours are close enough that `count` distinct, strictly increasing + /// `Double`s do not fit between them (adjacent doubles, or the duplicate-order tie). That is + /// the renumber trigger (01-storage-format.md § Ordering) and the caller's cue to compact and + /// ask again, exactly as an exhausted midpoint is everywhere else. + static func insertionRanks(amongVisible orders: [Double], at index: Int, count: Int) -> [Double]? { + guard count > 0 else { return [] } + if orders.isEmpty || index >= orders.count { + let base = orders.max() ?? 0 + return (1...count).map { base + gap * Double($0) } + } + if index <= 0 { + let base = orders.min() ?? 0 + // Ascending, and every value below `base`: the deepest is `count` gaps down. + return (1...count).map { base - gap * Double(count - $0 + 1) } + } + + let lower = orders[index - 1] + let upper = orders[index] + guard lower < upper else { return nil } + let step = (upper - lower) / Double(count + 1) + var ranks: [Double] = [] + var previous = lower + for position in 1...count { + let rank = lower + step * Double(position) + // Every rank must sit strictly inside the interval *and* strictly above the last one: + // at the precision floor the arithmetic silently collapses onto a neighbour, and a + // duplicate rank would hand display order to the folder-name tie-break. + guard rank > previous, rank < upper else { return nil } + ranks.append(rank) + previous = rank + } + return ranks + } + /// `count` fresh ranks, whole multiples of 1024 in ascending order /// (1024, 2048, …) — the renumber target when midpoint precision is /// exhausted. Deterministic by construction; the writer applies these, diff --git a/Kanban/UI/Board/DragAutoScrollMath.swift b/Kanban/UI/Board/DragAutoScrollMath.swift new file mode 100644 index 0000000..4d8cd5a --- /dev/null +++ b/Kanban/UI/Board/DragAutoScrollMath.swift @@ -0,0 +1,125 @@ +import CoreGraphics + +/// Edge-autoscroll geometry for a scroll view hosting drop targets, as pure arithmetic — no view, +/// no timer, no `NSScrollView` (`DragAutoScrollMathTests`). Ported from the pathfinder, whose +/// numbers are what was proven; the reasoning is reproduced because the behaviour is. +/// +/// A lane's cards live in a scroll view, so a lane taller than its viewport has landing spots below +/// the fold — and nothing in `DropSlotMath` can reach them, since the proposal is a function of the +/// cursor over the *visible* resting layout. A card session hovering near either end of a lane's +/// scroll area therefore scrolls it, continuously, until the pointer leaves the band or the drag +/// ends (DRAG-REORDER.md § Edge autoscroll). +/// +/// ## The geometry +/// +/// Along each axis the visible area owns an **activation band** of `band` points at either end. A +/// pointer inside a band scrolls that way at a speed that ramps with how deep into the band it +/// sits: `minSpeed` at the band's inner edge, `maxSpeed` at (and beyond) the visible area's own +/// edge. Outside both bands the velocity is exactly zero, so a drag that merely crosses the middle +/// of a lane never scrolls it. +/// +/// The `minSpeed` floor is deliberate: entering a band produces immediate, visible motion instead +/// of an imperceptible crawl that leaves the user wondering whether autoscroll exists at all. It is +/// the one discontinuity in the ramp, and it sits exactly on the band boundary, where the pointer +/// is moving anyway. +/// +/// The pointer may also sit *outside* the visible area and still drive it — generously above and +/// below (the lane's header and the strip's padding are still "this lane"), but barely sideways, so +/// a drag over the neighbouring lane never scrolls this one. `engagementRect` is that reach; a +/// pointer outside it drives nothing. +/// +/// Everything is axis-agnostic: the board strip has nothing to autoscroll today (every lane shares +/// the window width and the strip fills the window height — 03-board-ui.md § Layout), and the same +/// math would serve one unchanged if that ever changes. +/// +/// The ticking driver — the physical-mouse read, the re-resolved proposal on every step, the +/// structurally terminated task — is the drag session's, not this file's. +enum DragAutoScrollMath { + + /// Thickness of the activation band at each end of the visible area. + static let band: CGFloat = 56 + + /// Speed at the band's inner edge — the floor described above, in points/second. + static let minSpeed: CGFloat = 90 + + /// Speed at (and beyond) the visible area's own edge, in points/second. Deliberately not + /// faster: every scroll step re-resolves the drop proposal against the lane's resting grid, and + /// the distance the content travels between two resolutions is this speed divided by the tick + /// rate. + static let maxSpeed: CGFloat = 800 + + /// How far above the visible area the pointer may sit and still drive it — enough to cover the + /// lane's header, which is where a drag naturally goes to scroll up. + static let reachAbove: CGFloat = 48 + + /// The same below, covering the lane's bottom padding. + static let reachBelow: CGFloat = 24 + + /// The sideways reach — kept under half the distance between two lanes' scroll areas so only + /// one lane ever engages. + static let reachSide: CGFloat = 12 + + /// The region — in the visible area's own coordinates, `(0, 0)` at its top-left — a pointer + /// must be in to drive this scroller at all. + static func engagementRect(viewport: CGSize) -> CGRect { + CGRect(x: -reachSide, + y: -reachAbove, + width: viewport.width + reachSide * 2, + height: viewport.height + reachAbove + reachBelow) + } + + /// Signed scroll velocity in points/second for a pointer at `position` along an axis whose + /// visible extent runs `0...length`: negative scrolls toward the start (content moves + /// down/right), positive toward the end. + /// + /// `band` is clamped to half the extent, so the two bands of a short viewport meet rather than + /// overlap and its exact centre still resolves to "no scrolling". + static func velocity(position: CGFloat, + length: CGFloat, + band: CGFloat = band, + minSpeed: CGFloat = minSpeed, + maxSpeed: CGFloat = maxSpeed) -> CGFloat { + guard length > 0 else { return 0 } + let band = min(band, length / 2) + guard band > 0 else { return 0 } + + let depth: CGFloat + let direction: CGFloat + if position < band { + depth = (band - position) / band + direction = -1 + } else if position > length - band { + depth = (position - (length - band)) / band + direction = 1 + } else { + return 0 + } + return direction * (minSpeed + (maxSpeed - minSpeed) * min(max(depth, 0), 1)) + } + + /// Both axes at once for a pointer in the visible area's own coordinates. + static func velocity(pointer: CGPoint, + viewport: CGSize, + band: CGFloat = band, + minSpeed: CGFloat = minSpeed, + maxSpeed: CGFloat = maxSpeed) -> CGVector { + CGVector( + dx: velocity(position: pointer.x, length: viewport.width, + band: band, minSpeed: minSpeed, maxSpeed: maxSpeed), + dy: velocity(position: pointer.y, length: viewport.height, + band: band, minSpeed: minSpeed, maxSpeed: maxSpeed) + ) + } + + /// One tick's scroll offset: `current` advanced by `velocity` for `elapsed` seconds, clamped + /// into the scrollable range. An empty or inverted range (content shorter than the viewport) + /// pins to `minOffset`. + static func nextOffset(current: CGFloat, + velocity: CGFloat, + elapsed: CGFloat, + minOffset: CGFloat, + maxOffset: CGFloat) -> CGFloat { + let upper = max(minOffset, maxOffset) + return min(max(current + velocity * elapsed, minOffset), upper) + } +} diff --git a/Kanban/UI/Board/DropSlotMath.swift b/Kanban/UI/Board/DropSlotMath.swift new file mode 100644 index 0000000..e89664c --- /dev/null +++ b/Kanban/UI/Board/DropSlotMath.swift @@ -0,0 +1,287 @@ +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 `LaneReorderMath.centre` 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 + } +} diff --git a/Kanban/UI/Board/MasonryLayout.swift b/Kanban/UI/Board/MasonryLayout.swift index 6498df1..bc02365 100644 --- a/Kanban/UI/Board/MasonryLayout.swift +++ b/Kanban/UI/Board/MasonryLayout.swift @@ -1,5 +1,88 @@ +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"). @@ -30,7 +113,16 @@ struct MasonryLayout: Layout { private var columnCount: Int { max(1, columns) } private func columnWidth(for totalWidth: CGFloat) -> CGFloat { - max(0, (totalWidth - spacing * CGFloat(columnCount - 1)) / CGFloat(columnCount)) + 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 @@ -75,28 +167,30 @@ struct MasonryLayout: Layout { 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 column = columnWidth(for: width) - var heights = [CGFloat](repeating: 0, count: columnCount) - for index in subviews.indices { - let height = height(of: subviews, at: index, column: column, cache: &cache) - let target = index % columnCount - heights[target] += height + (heights[target] > 0 ? spacing : 0) - } - return CGSize(width: width, height: heights.max() ?? 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 column = columnWidth(for: bounds.width) - var y = [CGFloat](repeating: bounds.minY, count: columnCount) - for index in subviews.indices { - let target = index % columnCount - let x = bounds.minX + CGFloat(target) * (column + spacing) - let height = height(of: subviews, at: index, column: column, cache: &cache) - subviews[index].place(at: CGPoint(x: x, y: y[target]), - proposal: ProposedViewSize(width: column, height: height)) - y[target] += height + spacing + 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)) } } } diff --git a/Kanban/UI/Board/TrashLaneView.swift b/Kanban/UI/Board/TrashLaneView.swift index e1ec1c0..4e25d9f 100644 --- a/Kanban/UI/Board/TrashLaneView.swift +++ b/Kanban/UI/Board/TrashLaneView.swift @@ -396,7 +396,13 @@ private struct TrashEntryRow: View { // A drop over anything but a live lane — the trash itself, a gap, the outer margin — // writes nothing. There is no replica to snap back; the row never left. guard let lane = drag.laneUnder(value.location.x) else { return } - store.restoreByDrag(cardID: entry.id, intoLane: lane) + // m5-drag phase 2: the drop position comes from `DropSlotMath.cardSlot` once this + // gesture is replaced by the real drag session. Until then the interim is the + // destination lane's bottom, which is the index past its last rendered card. + let bottom = store.snapshot.lanes + .first { $0.id == lane }? + .cards.filter { !$0.isDeleted }.count ?? 0 + store.restoreByDrag(cardID: entry.id, intoLane: lane, at: bottom) } } diff --git a/KanbanTests/BoardWriterTests.swift b/KanbanTests/BoardWriterTests.swift index f9f8b53..d82c2cd 100644 --- a/KanbanTests/BoardWriterTests.swift +++ b/KanbanTests/BoardWriterTests.swift @@ -1571,6 +1571,94 @@ struct BoardWriterCopyTests { } } +// MARK: - Stripping a copied lane's tombstones + +/// `BoardWriter.stripTombstonedChildren` — the tail of a lane copy (04-interactions.md ▸ Drag and +/// drop: "A lane copy **strips tombstoned cards**"). `copyItem` copies the tree verbatim by +/// design, so the strip is the line after it rather than a filter inside it. +struct BoardWriterStripTombstonesTests { + + /// A tombstoned card, as an agent or a delete leaves it. + private static func tombstone(order: String, title: String) -> String { + "---\nschema: 1\ntitle: \(title)\norder: \(order)\ndeleted: 2026-03-03T09:00:00Z\n---\n\(title) body.\n" + } + + @Test func onlyTheTombstonedChildrenAreRemoved() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("A.kanban", Item.board) + let lane = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo")) + try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Live")) + try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card2)", + Self.tombstone(order: "2048", title: "Trashed")) + try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Also live")) + let live = try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)") + + let removed = try BoardWriter.stripTombstonedChildren(of: lane) + + #expect(removed == [ItemID(rawValue: Ident.card2)]) + #expect(!fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card2)")) + // Removed, never tombstoned, and the survivors are not rewritten on the way past. + #expect(fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card1)")) + #expect(fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card3)")) + #expect(try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)") == live) + #expect(try BoardLoader.load(boardRoot: fixture.url("A.kanban")).warnings.isEmpty) + } + + @Test func aWholeTombstonedFolderGoesWithItsAttachments() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("A.kanban", Item.board) + let lane = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo")) + try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", + Self.tombstone(order: "1024", title: "Trashed")) + try fixture.file("A.kanban/\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x89, 0x50])) + + _ = try BoardWriter.stripTombstonedChildren(of: lane) + + #expect(!fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card1)")) + #expect(try fixture.entryNames("A.kanban/\(Ident.lane1)") == ["index.md"]) + } + + @Test func nonUUIDStraysAndUnreadableChildrenAreLeftAlone() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("A.kanban", Item.board) + let lane = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo")) + // A stray is not a level at all; a UUID-shaped folder with no `index.md` cannot be asked + // the liveness question, and the conservative direction is to keep it. + try fixture.file("A.kanban/\(Ident.lane1)/notes/scratch.txt", Data("hand-written\n".utf8)) + try FileManager.default.createDirectory(at: fixture.url("A.kanban/\(Ident.lane1)/\(Ident.indexless)"), + withIntermediateDirectories: true) + + let removed = try BoardWriter.stripTombstonedChildren(of: lane) + + #expect(removed.isEmpty) + #expect(fixture.exists("A.kanban/\(Ident.lane1)/notes/scratch.txt")) + #expect(fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.indexless)")) + } + + @Test func aLaneWithNothingTombstonedIsUntouched() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("A.kanban", Item.board) + let lane = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo")) + try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Live")) + + #expect(try BoardWriter.stripTombstonedChildren(of: lane).isEmpty) + #expect(try fixture.entryNames("A.kanban/\(Ident.lane1)").sorted() == [Ident.card1, "index.md"].sorted()) + } + + @Test func aMissingFolderIsALoudErrorNamingTheCopy() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + + let error = writeFailure { _ = try BoardWriter.stripTombstonedChildren(of: fixture.url("A.kanban/\(Ident.lane1)")) } + // The user pressed nothing called "delete": a failure here must say the copy failed. + #expect(error?.operation == .copy(title: nil)) + } +} + // MARK: - Delete / Restore /// `BoardWriter.deleteItem`/`restoreItem` — the tombstone half of 01-storage-format.md § diff --git a/KanbanTests/DragAutoScrollMathTests.swift b/KanbanTests/DragAutoScrollMathTests.swift new file mode 100644 index 0000000..7f82a5e --- /dev/null +++ b/KanbanTests/DragAutoScrollMathTests.swift @@ -0,0 +1,166 @@ +import CoreGraphics +import Testing +@testable import Kanban + +/// `DragAutoScrollMath` — given a viewport and a pointer inside (or just outside) it, how fast, and +/// which way, should the scroll view move? Ported from the pathfinder's suite, whose numbers are +/// what was proven. The live driver is the drag session's; this is the decision it makes 60 times a +/// second (DRAG-REORDER.md § Edge autoscroll). + +private let length: CGFloat = 400 +private let band = DragAutoScrollMath.band +private let minSpeed = DragAutoScrollMath.minSpeed +private let maxSpeed = DragAutoScrollMath.maxSpeed + +private func velocity(_ position: CGFloat, length viewport: CGFloat = length) -> CGFloat { + DragAutoScrollMath.velocity(position: position, length: viewport) +} + +private func isClose(_ value: CGFloat, _ expected: CGFloat, _ tolerance: CGFloat = 0.0001) -> Bool { + abs(value - expected) <= tolerance +} + +@Suite("DragAutoScrollMath") +struct DragAutoScrollMathTests { + + // MARK: The neutral middle + + @Test("The middle of the viewport never scrolls") + func middleNeverScrolls() { + for position in stride(from: band, through: length - band, by: 8) { + #expect(velocity(position) == 0, "cursor \(position) is outside both bands") + } + // The band boundaries themselves are neutral — a band is the region strictly inside one. + #expect(velocity(band) == 0) + #expect(velocity(length - band) == 0) + } + + // MARK: Direction + + @Test("The leading band scrolls toward the start and the trailing band toward the end") + func direction() { + #expect(velocity(band - 1) < 0) + #expect(velocity(0) < 0) + #expect(velocity(length - band + 1) > 0) + #expect(velocity(length) > 0) + } + + // MARK: The ramp + + @Test("Speed ramps with edge proximity, on both ends") + func speedRampsWithProximity() { + var previous = abs(velocity(band - 0.5)) + for position in stride(from: band - 8, through: 0, by: -8) { + let speed = abs(velocity(position)) + #expect(speed > previous, "cursor \(position) should beat the shallower sample") + previous = speed + } + previous = abs(velocity(length - band + 0.5)) + for position in stride(from: length - band + 8, through: length, by: 8) { + let speed = abs(velocity(position)) + #expect(speed > previous, "cursor \(position) should beat the shallower sample") + previous = speed + } + } + + @Test("The ramp spans the floor to the ceiling, linearly") + func rampIsLinearBetweenFloorAndCeiling() { + // Just inside the band: the floor, which exists so entering a band produces visible motion + // rather than an imperceptible crawl. At the viewport edge: the ceiling. Halfway: the mean. + #expect(isClose(abs(velocity(band - 0.0001)), minSpeed, 0.01)) + #expect(isClose(abs(velocity(0)), maxSpeed)) + #expect(isClose(abs(velocity(band / 2)), (minSpeed + maxSpeed) / 2)) + #expect(isClose(abs(velocity(length)), maxSpeed)) + #expect(isClose(abs(velocity(length - band / 2)), (minSpeed + maxSpeed) / 2)) + } + + @Test("Beyond the viewport edge the speed saturates rather than growing") + func saturatesBeyondTheEdge() { + // A pointer over the lane header (above the scroll area) or below its bottom padding drives + // the fastest scroll, never faster. + #expect(isClose(velocity(-40), -maxSpeed)) + #expect(isClose(velocity(-4000), -maxSpeed)) + #expect(isClose(velocity(length + 40), maxSpeed)) + } + + // MARK: Degenerate viewports + + @Test("A short viewport halves its bands instead of overlapping them") + func shortViewport() { + let short: CGFloat = 60 + #expect(velocity(30, length: short) == 0, "the exact centre still resolves to no scrolling") + #expect(velocity(29, length: short) < 0) + #expect(velocity(31, length: short) > 0) + #expect(isClose(abs(velocity(0, length: short)), maxSpeed)) + } + + @Test("An empty or inverted viewport never scrolls") + func emptyViewport() { + #expect(velocity(0, length: 0) == 0) + #expect(velocity(10, length: -5) == 0) + } + + // MARK: Two axes + + @Test("The two axes are resolved independently") + func axesAreIndependent() { + let viewport = CGSize(width: 400, height: 400) + let bottom = DragAutoScrollMath.velocity(pointer: CGPoint(x: 200, y: 390), viewport: viewport) + #expect(bottom.dx == 0) + #expect(bottom.dy > 0) + + let corner = DragAutoScrollMath.velocity(pointer: CGPoint(x: 2, y: 2), viewport: viewport) + #expect(corner.dx < 0) + #expect(corner.dy < 0) + + let centre = DragAutoScrollMath.velocity(pointer: CGPoint(x: 200, y: 200), viewport: viewport) + #expect(centre.dx == 0) + #expect(centre.dy == 0) + } + + // MARK: Engagement reach + + @Test("Engagement reaches over the header but barely sideways") + func engagementReach() { + let viewport = CGSize(width: 240, height: 400) + let reach = DragAutoScrollMath.engagementRect(viewport: viewport) + + #expect(reach.contains(CGPoint(x: 120, y: 200)), "inside the visible area, always") + + // Above it (the lane header) and below it (the strip's padding). + #expect(reach.contains(CGPoint(x: 120, y: -DragAutoScrollMath.reachAbove + 1))) + #expect(reach.contains(CGPoint(x: 120, y: viewport.height + DragAutoScrollMath.reachBelow - 1))) + #expect(!reach.contains(CGPoint(x: 120, y: -DragAutoScrollMath.reachAbove - 1))) + #expect(!reach.contains(CGPoint(x: 120, y: viewport.height + DragAutoScrollMath.reachBelow + 1))) + + // Sideways: only a sliver, so the neighbouring lane never engages. + #expect(reach.contains(CGPoint(x: -DragAutoScrollMath.reachSide + 1, y: 200))) + #expect(!reach.contains(CGPoint(x: -DragAutoScrollMath.reachSide - 1, y: 200))) + #expect(!reach.contains(CGPoint(x: viewport.width + DragAutoScrollMath.reachSide + 1, y: 200))) + + // The sideways reach must stay under half the distance between two lanes' scroll areas, or + // two lanes would scroll at once. + #expect(DragAutoScrollMath.reachSide < 28 / 2) + } + + // MARK: Stepping the offset + + @Test("One tick advances the offset by velocity × elapsed") + func nextOffsetAdvances() { + #expect(DragAutoScrollMath.nextOffset(current: 100, velocity: 600, elapsed: 0.5, + minOffset: 0, maxOffset: 1000) == 400) + #expect(DragAutoScrollMath.nextOffset(current: 100, velocity: -600, elapsed: 0.1, + minOffset: 0, maxOffset: 1000) == 40) + } + + @Test("A tick clamps into the scrollable range") + func nextOffsetClamps() { + #expect(DragAutoScrollMath.nextOffset(current: 10, velocity: -800, elapsed: 1, + minOffset: 0, maxOffset: 1000) == 0) + #expect(DragAutoScrollMath.nextOffset(current: 990, velocity: 800, elapsed: 1, + minOffset: 0, maxOffset: 1000) == 1000) + // Content shorter than the viewport: nothing to scroll, pin to the top. + #expect(DragAutoScrollMath.nextOffset(current: 0, velocity: 800, elapsed: 1, + minOffset: 0, maxOffset: -120) == 0) + } +} diff --git a/KanbanTests/DragWriteTests.swift b/KanbanTests/DragWriteTests.swift new file mode 100644 index 0000000..3bbbc58 --- /dev/null +++ b/KanbanTests/DragWriteTests.swift @@ -0,0 +1,632 @@ +import Foundation +import Testing +@testable import Kanban + +/// `BoardStore`'s drop commits — the writes a released drag performs (04-interactions.md ▸ Drag and +/// drop, DRAG-REORDER.md § The drop commits). +/// +/// Like every other write suite here these drive a **real store over a real temp board** and then +/// read back through the loader or the raw bytes, never through a snapshot the store handed out: +/// the interesting claims are about the files — which rank landed, which folder travelled, which +/// UUID was reminted, and which sibling was left alone. `WriterFixture`, `Ident` and `Item` come +/// from `WriterTestSupport.swift`. +/// +/// The geometry that produces the `index` these methods take is `DropSlotMathTests`'; here the +/// index is simply given, which is the whole point of the split. + +// MARK: - Fixtures + +private func tombstoned(order: String, title: String) -> String { + """ + --- + schema: 1 + title: \(title) + order: \(order) + created: 2026-01-01T09:00:00Z + deleted: 2026-03-03T09:00:00Z + --- + \(title) body. + + """ +} + +/// Three cards in the first lane, one in the second — enough room for a run of two to insert +/// between siblings without either end being the answer. +@MainActor +private func makeBoard() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) + try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) + try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third")) + try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) + try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth")) + return fixture +} + +/// Identities the destination board has never seen — the source board's own, for every cross-board +/// case that is *not* about the import boundary. `Ident` is shared with the writer suites and +/// deliberately small; a second board needs its own namespace to be a second board at all. +private enum Foreign { + static let lane = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + static let card = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" + static let second = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" + static let trashed = "dddddddd-dddd-4ddd-8ddd-dddddddddddd" +} + +/// The board every cross-board test drags *out of*: one lane holding a live card and a tombstoned +/// one, so the lane-copy rule has something to strip and the restore rules have a row to carry. +/// +/// `colliding` puts the lane and its live card under identities the **destination** already holds, +/// which is the import boundary's whole question; the tombstoned card keeps its foreign identity +/// either way, so a colliding arrival can prove the degradation is per folder. +@MainActor +private func makeSourceBoard(colliding: Bool = false) throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + let laneName = colliding ? Ident.lane1 : Foreign.lane + let cardName = colliding ? Ident.card1 : Foreign.card + try fixture.item(laneName, Item.rich(order: "1024", title: "Imported")) + try fixture.item("\(laneName)/\(cardName)", Item.rich(order: "1024", title: "Travelling")) + try fixture.item("\(laneName)/\(Foreign.trashed)", tombstoned(order: "2048", title: "Trashed")) + return fixture +} + +private let lane1 = ItemID(rawValue: Ident.lane1) +private let lane2 = ItemID(rawValue: Ident.lane2) +private let lane3 = ItemID(rawValue: Ident.lane3) +private let card1 = ItemID(rawValue: Ident.card1) +private let card2 = ItemID(rawValue: Ident.card2) +private let card3 = ItemID(rawValue: Ident.card3) +private let card4 = ItemID(rawValue: Ident.card4) + +/// The board as the loader sees it — never the store's snapshot, which a drop deliberately does not +/// touch (the one-way flow: the write lands, the watcher reloads). +private func loaded(_ fixture: WriterFixture) throws -> BoardModel { + try BoardLoader.load(boardRoot: fixture.root).model +} + +/// A lane's rendered card titles, in display order. +private func titles(_ laneID: ItemID, in fixture: WriterFixture) throws -> [String] { + guard let lane = try loaded(fixture).lanes.first(where: { $0.id == laneID }) else { return [] } + return lane.cards.filter { !$0.isDeleted }.compactMap(\.title.value) +} + +/// A lane's rendered card folder names, in display order — identity, where titles would not +/// distinguish an original from its copy. +private func ids(_ laneID: ItemID, in fixture: WriterFixture) throws -> [String] { + guard let lane = try loaded(fixture).lanes.first(where: { $0.id == laneID }) else { return [] } + return lane.cards.filter { !$0.isDeleted }.map(\.id.rawValue) +} + +private func order(_ fixture: WriterFixture, _ relativePath: String) throws -> FieldValue { + try FrontmatterDocument.parse(fixture.indexText(relativePath)).order +} + +/// A file's mtime — "this sibling was not rewritten", stated the way `WriteFidelityTests` states it. +private func stat(_ fixture: WriterFixture, _ relativePath: String) throws -> Date { + let indexURL = fixture.url(relativePath).appendingPathComponent("index.md") + let attributes = try FileManager.default.attributesOfItem(atPath: indexURL.path) + guard let modified = attributes[.modificationDate] as? Date else { + Issue.record("no modification date for \(relativePath)") + return .distantPast + } + return modified +} + +// MARK: - Within-board card moves + +@MainActor +@Suite("BoardStore ▸ moveCards") +struct MoveCardsTests { + + @Test("A same-lane drop rewrites only the card that moved") + func sameLaneReorder() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let first = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") + let second = try stat(fixture, "\(Ident.lane1)/\(Ident.card2)") + + // Third to the head: the index is counted with the dragged card already removed, so 0 is + // "before what is left", which is First. + store.moveCards([card3], toLane: lane1, at: 0) + + #expect(try titles(lane1, in: fixture) == ["Third", "First", "Second"]) + // A head insert over the remaining ranks [1024, 2048]. + #expect(try order(fixture, "\(Ident.lane1)/\(Ident.card3)") == .valid(0)) + // Ranks are inserted, never permuted, so the siblings' files were never opened. + #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") == first) + #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card2)") == second) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("A cross-lane drop inserts the run contiguously, in flatten order") + func crossLaneContiguousInsert() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + // A Set, deliberately unordered: the run lands in *flatten* order — lane `order` first, + // then card `order` — so Second (lane one) precedes Fourth (lane two) whatever the Set did. + store.moveCards([card4, card2], toLane: lane1, at: 1) + + #expect(try titles(lane1, in: fixture) == ["First", "Second", "Fourth", "Third"]) + #expect(try titles(lane2, in: fixture) == [], "the arrival's folder really left lane two") + #expect(!fixture.exists("\(Ident.lane2)/\(Ident.card4)")) + #expect(fixture.exists("\(Ident.lane1)/\(Ident.card4)")) + // A within-board move never remints: the UUIDs travelled unchanged. + #expect(try ids(lane1, in: fixture) == [Ident.card1, Ident.card2, Ident.card4, Ident.card3]) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("A drop that lands where everything already is writes nothing") + func ownSlotIsANoOp() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let stamps = [ + try stat(fixture, "\(Ident.lane1)/\(Ident.card1)"), + try stat(fixture, "\(Ident.lane1)/\(Ident.card2)"), + try stat(fixture, "\(Ident.lane1)/\(Ident.card3)"), + ] + + // Second's own resting slot: with it removed the lane reads [First, Third], and 1 puts it + // straight back between them. + store.moveCards([card2], toLane: lane1, at: 1) + + #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") == stamps[0]) + #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card2)") == stamps[1], + "a drag that ends where it started must not stamp modified or mint a commit") + #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card3)") == stamps[2]) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("A destination that is gone, tombstoned, or empty of members writes nothing") + func noOps() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Gone")) + let store = try BoardStore(rootURL: fixture.root) + let stamp = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") + + store.moveCards([card1], toLane: lane3, at: 0) // tombstoned lane + store.moveCards([card1], toLane: ItemID(rawValue: Ident.indexless), at: 0) // no such lane + store.moveCards([], toLane: lane2, at: 0) // nothing dragged + store.moveCards([ItemID(rawValue: Ident.indexless)], toLane: lane2, at: 0) // nothing live + + #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") == stamp) + #expect(try titles(lane2, in: fixture) == ["Fourth"]) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("An out-of-range index clamps rather than trapping") + func indexClamps() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + // A proposal computed against a snapshot one reload old must not trap. + store.moveCards([card4], toLane: lane1, at: 99) + + #expect(try titles(lane1, in: fixture) == ["First", "Second", "Third", "Fourth"]) + } + + @Test("Duplicate ranks trigger a renumber, then the run places against the fresh ladder") + func renumberFallback() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + // Two cards sharing a rank: no `Double` fits between them, which is the renumber trigger. + try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "1024", title: "Second")) + let store = try BoardStore(rootURL: fixture.root) + + store.moveCards([card3], toLane: lane1, at: 1) + + #expect(try titles(lane1, in: fixture) == ["First", "Third", "Second"]) + // The lane was compacted to the 1024 ladder first, so the interior midpoint exists again. + #expect(try order(fixture, "\(Ident.lane1)/\(Ident.card1)") == .valid(1024)) + #expect(try order(fixture, "\(Ident.lane1)/\(Ident.card3)") == .valid(1536)) + #expect(try order(fixture, "\(Ident.lane1)/\(Ident.card2)") == .valid(2048)) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("A read-only board refuses the drop") + func readOnlyRefuses() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + store.enterVanishedRootLock() + + store.moveCards([card3], toLane: lane1, at: 0) + + #expect(try titles(lane1, in: fixture) == ["First", "Second", "Third"]) + } +} + +// MARK: - Within-board ⌥-copies + +@MainActor +@Suite("BoardStore ▸ copyCards") +struct CopyCardsTests { + + @Test("A copy lands fresh-GUID duplicates at the drop and leaves the originals alone") + func freshGUIDsAndUntouchedOriginals() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let stamp = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") + + // With First lifted the lane reads [Second, Third]; 1 is "before Third". + store.copyCards([card1], toLane: lane1, at: 1) + + #expect(try titles(lane1, in: fixture) == ["First", "Second", "First", "Third"]) + let landed = try ids(lane1, in: fixture) + #expect(landed.count == 4) + #expect(landed[2] != Ident.card1, "a copy mints a fresh UUID at every level") + #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") == stamp, "the original is untouched") + #expect(store.banners.oneShots.isEmpty) + } + + @Test("A copy keeps created — a copy is a fork") + func createdIsKept() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.copyCards([card1], toLane: lane2, at: 0) + + let landedIDs = try ids(lane2, in: fixture) + let landed = try #require(landedIDs.first { $0 != Ident.card4 }) + let text = try fixture.indexText("\(Ident.lane2)/\(landed)") + #expect(text.contains("created: 2026-01-01T09:00:00Z")) + #expect(!text.contains("modified-by"), "a copy is an app write, so the foreign stamp is cleared") + } + + @Test("A copy's ranks are placed among the originals, which are still there") + func ranksAvoidTheOriginals() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + // Index 0 in the resting layout means "before Second" — the layout the originals are lifted + // out of. The rank has to sit between First and Second, not at First's apparently vacated + // 1024, because First reappears the instant the write lands. + store.copyCards([card1], toLane: lane1, at: 0) + + #expect(try titles(lane1, in: fixture) == ["First", "First", "Second", "Third"]) + let landed = try ids(lane1, in: fixture)[1] + #expect(try order(fixture, "\(Ident.lane1)/\(landed)") == .valid(1536)) + } + + @Test("A multi-copy lands the run contiguously, in flatten order") + func multiCopyIsContiguous() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.copyCards([card3, card1], toLane: lane2, at: 1) + + #expect(try titles(lane2, in: fixture) == ["Fourth", "First", "Third"]) + #expect(try titles(lane1, in: fixture) == ["First", "Second", "Third"], "originals stay") + } + + @Test("Nothing droppable copies nothing") + func noOps() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Gone")) + let store = try BoardStore(rootURL: fixture.root) + + store.copyCards([card1], toLane: lane3, at: 0) + store.copyCards([], toLane: lane2, at: 0) + + #expect(try titles(lane2, in: fixture) == ["Fourth"]) + #expect(try fixture.entryNames(Ident.lane3) == ["index.md"]) + #expect(store.banners.oneShots.isEmpty) + } +} + +// MARK: - Cross-board card arrivals + +@MainActor +@Suite("BoardStore ▸ receiveCards") +struct ReceiveCardsTests { + + @Test("A cross-board copy lands a fresh-GUID duplicate and leaves the source alone") + func crossBoardCopy() throws { + let destination = try makeBoard() + defer { destination.tearDown() } + let source = try makeSourceBoard() + defer { source.tearDown() } + let store = try BoardStore(rootURL: destination.root) + + store.receiveCards([source.url("\(Foreign.lane)/\(Foreign.card)")], + operation: .copy, toLane: lane2, at: 0) + + #expect(try titles(lane2, in: destination) == ["Travelling", "Fourth"]) + let landedIDs = try ids(lane2, in: destination) + #expect(landedIDs.first != Foreign.card, "copies mint fresh UUIDs, always") + #expect(source.exists("\(Foreign.lane)/\(Foreign.card)"), "the original stays") + #expect(store.banners.oneShots.isEmpty) + } + + @Test("A cross-board move carries the identity and empties the source folder") + func crossBoardMove() throws { + let destination = try makeBoard() + defer { destination.tearDown() } + let source = try makeSourceBoard() + defer { source.tearDown() } + let store = try BoardStore(rootURL: destination.root) + + store.receiveCards([source.url("\(Foreign.lane)/\(Foreign.card)")], + operation: .move, toLane: lane2, at: 1) + + #expect(try ids(lane2, in: destination) == [Ident.card4, Foreign.card], "identity travels") + #expect(!source.exists("\(Foreign.lane)/\(Foreign.card)")) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("A moved folder whose UUID the destination already holds arrives reminted") + func importBoundaryRemints() throws { + let destination = try makeBoard() + defer { destination.tearDown() } + // The source's card carries `card1`, which lives in the destination's first lane already. + let source = try makeSourceBoard(colliding: true) + defer { source.tearDown() } + let store = try BoardStore(rootURL: destination.root) + + store.receiveCards([source.url("\(Ident.lane1)/\(Ident.card1)")], + operation: .move, toLane: lane2, at: 1) + + let landed = try ids(lane2, in: destination) + #expect(landed.count == 2) + #expect(landed[1] != Ident.card1, "a colliding UUID is repaired at the import boundary") + #expect(destination.exists("\(Ident.lane1)/\(Ident.card1)"), "the resident keeps its identity") + #expect(try titles(lane2, in: destination) == ["Fourth", "Travelling"]) + #expect(!source.exists("\(Ident.lane1)/\(Ident.card1)")) + } + + @Test("A cross-board run lands contiguously at the drop, in the order given") + func runLandsContiguously() throws { + let destination = try makeBoard() + defer { destination.tearDown() } + let source = try makeSourceBoard() + defer { source.tearDown() } + try source.item("\(Foreign.lane)/\(Foreign.second)", Item.rich(order: "3072", title: "Second traveller")) + let store = try BoardStore(rootURL: destination.root) + + store.receiveCards([ + source.url("\(Foreign.lane)/\(Foreign.card)"), + source.url("\(Foreign.lane)/\(Foreign.second)"), + ], operation: .copy, toLane: lane1, at: 1) + + #expect(try titles(lane1, in: destination) + == ["First", "Travelling", "Second traveller", "Second", "Third"]) + } + + @Test("Nothing droppable receives nothing") + func noOps() throws { + let destination = try makeBoard() + defer { destination.tearDown() } + let source = try makeSourceBoard() + defer { source.tearDown() } + try destination.item(Ident.lane3, tombstoned(order: "3072", title: "Gone")) + let store = try BoardStore(rootURL: destination.root) + + store.receiveCards([source.url("\(Foreign.lane)/\(Foreign.card)")], + operation: .copy, toLane: lane3, at: 0) + store.receiveCards([], operation: .copy, toLane: lane2, at: 0) + + #expect(try destination.entryNames(Ident.lane3) == ["index.md"]) + #expect(try titles(lane2, in: destination) == ["Fourth"]) + #expect(source.exists("\(Foreign.lane)/\(Foreign.card)")) + #expect(store.banners.oneShots.isEmpty) + } +} + +// MARK: - Cross-board lane arrivals + +@MainActor +@Suite("BoardStore ▸ receiveLanes") +struct ReceiveLanesTests { + + @Test("A lane copy transfers the content and strips the tombstoned cards") + func laneCopyStripsTombstones() throws { + let destination = try makeBoard() + defer { destination.tearDown() } + let source = try makeSourceBoard() + defer { source.tearDown() } + let store = try BoardStore(rootURL: destination.root) + + store.receiveLanes([source.url(Foreign.lane)], operation: .copy, at: 0) + + let model = try loaded(destination) + #expect(model.lanes.map(\.title.value) == ["Imported", "Todo", "Doing"]) + let arrived = try #require(model.lanes.first) + #expect(arrived.id.rawValue != Foreign.lane, "a copy mints fresh UUIDs at every level") + #expect(arrived.cards.map(\.title.value) == ["Travelling"], + "trash isn't content — the tombstoned card did not come") + #expect(arrived.cards.allSatisfy { !$0.isDeleted }) + #expect(arrived.cards[0].id.rawValue != Foreign.card, "a copied lane's cards are new cards") + + // The tombstoned original stays recoverable in the source board. + #expect(source.exists("\(Foreign.lane)/\(Foreign.trashed)")) + #expect(try source.indexText("\(Foreign.lane)/\(Foreign.trashed)").contains("deleted:")) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("A lane move carries its tombstoned cards whole, into the destination's trash") + func laneMoveCarriesTombstones() throws { + let destination = try makeBoard() + defer { destination.tearDown() } + let source = try makeSourceBoard() + defer { source.tearDown() } + let store = try BoardStore(rootURL: destination.root) + + store.receiveLanes([source.url(Foreign.lane)], operation: .move, at: 2) + + let model = try loaded(destination) + #expect(model.lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane2, Foreign.lane], + "identity travels, and the drop position is honoured") + let arrived = try #require(model.lanes.last) + #expect(arrived.cards.map(\.id.rawValue) == [Foreign.card, Foreign.trashed]) + #expect(arrived.cards[1].isDeleted, "the tombstone came along as-is") + #expect(TrashModel.entries(of: model).map(\.id) == [ItemID(rawValue: Foreign.trashed)], + "and it renders in the destination's trash") + #expect(!source.exists(Foreign.lane)) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("A colliding lane move remints only the folders that collide") + func laneMoveRemintsPerFolder() throws { + let destination = try makeBoard() + defer { destination.tearDown() } + // The source lane is `lane1` holding `card1` — both already live in the destination — plus + // one tombstoned card whose identity is foreign. + let source = try makeSourceBoard(colliding: true) + defer { source.tearDown() } + let store = try BoardStore(rootURL: destination.root) + + store.receiveLanes([source.url(Ident.lane1)], operation: .move, at: 2) + + let model = try loaded(destination) + #expect(model.lanes.count == 3) + let arrived = try #require(model.lanes.last) + #expect(arrived.id.rawValue != Ident.lane1, "the colliding root was repaired") + #expect(arrived.title.value == "Imported") + let arrivedCards = arrived.cards.map(\.id.rawValue) + #expect(arrivedCards.count == 2) + #expect(arrivedCards[0] != Ident.card1, "the colliding card was repaired too") + #expect(arrivedCards[1] == Foreign.trashed, "and nothing else was — degradation is per folder") + #expect(try titles(lane1, in: destination) == ["First", "Second", "Third"], + "the residents kept their identities and their ranks") + } + + @Test("Nothing to receive writes nothing") + func noOps() throws { + let destination = try makeBoard() + defer { destination.tearDown() } + let store = try BoardStore(rootURL: destination.root) + + store.receiveLanes([], operation: .copy, at: 0) + + #expect(try loaded(destination).lanes.count == 2) + #expect(store.banners.oneShots.isEmpty) + } +} + +// MARK: - Drag to restore, positionally + +/// A lane holding a live card, a tombstoned one, and another live one — so a restore has somewhere +/// to land that is neither the head nor the tail. +@MainActor +private func makeTrashBoard() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) + try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Trashed")) + try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third")) + try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) + try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth")) + return fixture +} + +@MainActor +@Suite("BoardStore ▸ positional drag-to-restore") +struct RestoreByDragPositionTests { + + @Test("The drop position sets the restored card's order") + func dropPositionSetsTheOrder() throws { + let fixture = try makeTrashBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + // Index 0 among the lane's two live cards: ahead of both, not back at its recorded 2048. + store.restoreByDrag(cardID: card2, intoLane: lane1, at: 0) + + #expect(try titles(lane1, in: fixture) == ["Trashed", "First", "Third"]) + #expect(try order(fixture, "\(Ident.lane1)/\(Ident.card2)") == .valid(0)) + #expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"), "same lane never moves a folder") + #expect(TrashModel.isEmpty(try loaded(fixture))) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("A cross-lane restore lands at the drop position, not at the bottom") + func crossLanePositional() throws { + let fixture = try makeTrashBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.restoreByDrag(cardID: card2, intoLane: lane2, at: 0) + + #expect(try titles(lane2, in: fixture) == ["Trashed", "Fourth"]) + #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)")) + #expect(try order(fixture, "\(Ident.lane2)/\(Ident.card2)") == .valid(0)) + let text = try fixture.indexText("\(Ident.lane2)/\(Ident.card2)") + #expect(!text.contains("deleted:")) + } + + @Test("An out-of-range index clamps to the lane's bottom") + func indexClamps() throws { + let fixture = try makeTrashBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.restoreByDrag(cardID: card2, intoLane: lane1, at: 99) + + #expect(try titles(lane1, in: fixture) == ["First", "Third", "Trashed"]) + #expect(try order(fixture, "\(Ident.lane1)/\(Ident.card2)") == .valid(4096)) + } +} + +@MainActor +@Suite("BoardStore ▸ receiveRestoredCards") +struct ReceiveRestoredCardsTests { + + @Test("A cross-board restore-copy lands live and leaves the source tombstone standing") + func restoreCopyStripsDeletedAndKeepsTheOriginal() throws { + let destination = try makeBoard() + defer { destination.tearDown() } + let source = try makeSourceBoard() + defer { source.tearDown() } + let store = try BoardStore(rootURL: destination.root) + + store.receiveRestoredCards([source.url("\(Foreign.lane)/\(Foreign.trashed)")], + operation: .copy, toLane: lane2, at: 0) + + #expect(try titles(lane2, in: destination) == ["Trashed", "Fourth"]) + let landedIDs = try ids(lane2, in: destination) + let landed = try #require(landedIDs.first) + #expect(landed != Foreign.trashed, "a copy out of the trash is still a copy") + let text = try destination.indexText("\(Ident.lane2)/\(landed)") + #expect(!text.contains("deleted:"), "`deleted:` is stripped on paste/duplicate/drop") + #expect(text.contains("created: 2026-01-01T09:00:00Z"), "a copy is a fork") + + // The tombstoned original stays recoverable in the source board's trash. + #expect(source.exists("\(Foreign.lane)/\(Foreign.trashed)")) + #expect(try source.indexText("\(Foreign.lane)/\(Foreign.trashed)").contains("deleted:")) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("A cross-board restore-move clears the tombstone and the source loses the folder") + func restoreMoveClearsTheTombstone() throws { + let destination = try makeBoard() + defer { destination.tearDown() } + let source = try makeSourceBoard() + defer { source.tearDown() } + let store = try BoardStore(rootURL: destination.root) + + store.receiveRestoredCards([source.url("\(Foreign.lane)/\(Foreign.trashed)")], + operation: .move, toLane: lane2, at: 1) + + #expect(try ids(lane2, in: destination) == [Ident.card4, Foreign.trashed], "identity travels") + let text = try destination.indexText("\(Ident.lane2)/\(Foreign.trashed)") + #expect(!text.contains("deleted:")) + #expect(!source.exists("\(Foreign.lane)/\(Foreign.trashed)"), "the tombstone left the source") + #expect(TrashModel.isEmpty(try loaded(source))) + #expect(TrashModel.isEmpty(try loaded(destination))) + #expect(store.banners.oneShots.isEmpty) + } +} diff --git a/KanbanTests/DropSlotMathTests.swift b/KanbanTests/DropSlotMathTests.swift new file mode 100644 index 0000000..71f8d49 --- /dev/null +++ b/KanbanTests/DropSlotMathTests.swift @@ -0,0 +1,481 @@ +import CoreGraphics +import Testing +@testable import Kanban + +/// `DropSlotMath` — where a drag would land, as arithmetic. The model is DRAG-REORDER.md; these +/// pin it rule for rule, ported from the pathfinder's `DropSlotTests` and extended for the two +/// things Lanework has that it did not: a masonry card grid that is genuinely two-dimensional from +/// day one, and a hysteresis contract that says "hold" with `nil` rather than by echoing the +/// caller's own value back at it. + +// MARK: - Zones (one axis) + +/// Three cards of height 40 with an 8pt gap, starting at y = 0: +/// card 0: [0, 40] · card 1: [48, 88] · card 2: [96, 136] +/// Zone boundaries: the gap midpoints 44 and 92, then the last edge plus half a gap, 140. +/// slot 0 (−∞, 44) · slot 1 [44, 92) · slot 2 [92, 140) · slot 3 [140, ∞) +private let extents: [ClosedRange] = [0...40, 48...88, 96...136] +private let gap: CGFloat = 8 +private var boundaries: [CGFloat] { DropSlotMath.zoneBoundaries(extents: extents, gap: gap) } + +@Suite("DropSlotMath ▸ zones") +struct DropSlotZoneTests { + + @Test("Zone boundaries are the gap midpoints, plus half a gap past the last item") + func zoneBoundariesTile() { + #expect(boundaries == [44, 92, 140]) + #expect(DropSlotMath.zoneBoundaries(extents: [], gap: gap) == []) + #expect(DropSlotMath.zoneBoundaries(extents: [10...50], gap: gap) == [54]) + } + + @Test("Anywhere over an item claims its slot, whatever was proposed before") + func anywhereOverAnItemClaimsIt() { + for y: CGFloat in [48, 60, 68, 80, 88] { + for current in [nil, 0, 1, 2, 3] { + #expect(DropSlotMath.containingSlot(cursor: y, boundaries: boundaries, current: current) == 1, + "cursor \(y) is over item 1 (current \(String(describing: current)))") + } + } + // The half-gap flanks belong to the zone too — the zones tile with no dead space. + #expect(DropSlotMath.containingSlot(cursor: 45, boundaries: boundaries, current: 0) == 1) + #expect(DropSlotMath.containingSlot(cursor: 91, boundaries: boundaries, current: 2) == 1) + } + + @Test("A zone is entered exactly at its border, and left only by entering another") + func enteredAtTheBorder() { + #expect(DropSlotMath.containingSlot(cursor: 44.0001, boundaries: boundaries, current: 0) == 1) + #expect(DropSlotMath.containingSlot(cursor: 43.9999, boundaries: boundaries, current: 1) == 0) + #expect(DropSlotMath.containingSlot(cursor: 140.0001, boundaries: boundaries, current: 2) == 3) + + for y in stride(from: 44.5, through: 91.5, by: 0.5) { + #expect(DropSlotMath.containingSlot(cursor: CGFloat(y), boundaries: boundaries, current: 1) == 1, + "cursor \(y) is inside slot 1's zone; the proposal must hold") + } + } + + @Test("Past the last item is the end slot") + func pastTheLastItem() { + #expect(DropSlotMath.containingSlot(cursor: 141, boundaries: boundaries, current: nil) == 3) + #expect(DropSlotMath.containingSlot(cursor: 500, boundaries: boundaries, current: 0) == 3) + } + + @Test("Picking an item up over its own resting spot proposes its own slot — a no-op") + func ownSlotPickupIsANoOp() { + for y: CGFloat in [48, 55, 68, 80, 88] { + #expect(DropSlotMath.containingSlot(cursor: y, boundaries: boundaries, current: 1) == 1) + } + var index = 1 + for _ in 0..<10 { + index = DropSlotMath.containingSlot(cursor: 68, boundaries: boundaries, current: index) + } + #expect(index == 1, "re-evaluating the same cursor is a fixed point") + } + + @Test("A cursor on an exact boundary keeps whichever adjoining slot is proposed") + func exactBoundaryTie() { + #expect(DropSlotMath.containingSlot(cursor: 92, boundaries: boundaries, current: 1) == 1) + #expect(DropSlotMath.containingSlot(cursor: 92, boundaries: boundaries, current: 2) == 2) + + var index = 1 + for _ in 0..<10 { + index = DropSlotMath.containingSlot(cursor: 92, boundaries: boundaries, current: index) + } + #expect(index == 1, "the boundary pixel is a fixed point, so the shadow cannot oscillate") + + // A non-adjacent current has no claim on the tie; the border rule wins. + #expect(DropSlotMath.containingSlot(cursor: 92, boundaries: boundaries, current: 0) == 2) + #expect(DropSlotMath.containingSlot(cursor: 92, boundaries: boundaries, current: nil) == 2) + } + + @Test("Degenerate inputs are total") + func degenerateInputs() { + #expect(DropSlotMath.containingSlot(cursor: 123, boundaries: [], current: nil) == 0) + #expect(DropSlotMath.containingSlot(cursor: 123, boundaries: [], current: 0) == 0) + // An index from a snapshot one reload old is ignored rather than trusted. + #expect(DropSlotMath.containingSlot(cursor: 116, boundaries: boundaries, current: 99) == 2) + #expect(DropSlotMath.containingSlot(cursor: 116, boundaries: boundaries, current: -1) == 2) + } +} + +// MARK: - Span-capped triggers + +/// Lanes along x with an 8pt gap: a 1× (100), a 3× (320), a 1× (100). +/// lane 0: [0, 100] · lane 1: [108, 428] · lane 2: [436, 536] +/// Zone boundaries: 104, 432, 540. Dragging a 1× lane (span 100): +/// slot 1's trigger = [104, 104 + 100 + 8 = 212]; (212, 432) is dead. +@Suite("DropSlotMath ▸ span-capped triggers") +struct SpanCappedSlotTests { + private let extents: [ClosedRange] = [0...100, 108...428, 436...536] + private let gap: CGFloat = 8 + private let narrowSpan: CGFloat = 100 + + private func slot(_ cursor: CGFloat, current: Int?, span: CGFloat? = nil) -> Int? { + DropSlotMath.slot(cursor: cursor, extents: extents, gap: gap, + draggedSpan: span ?? narrowSpan, current: current) + } + + @Test("A slot triggers over the footprint the dragged run would actually occupy") + func triggerIsTheFutureFootprint() { + // The near side of the wide lane — where the dragged lane would land — claims slot 1 from + // any prior proposal. (x = 104 exactly is the boundary pixel, owned by the tie rule.) + for x: CGFloat in [105, 110, 160, 212] { + for current in [nil, 0, 1, 2, 3] { + #expect(slot(x, current: current) == 1, + "cursor \(x) is inside slot 1's trigger (current \(String(describing: current)))") + } + } + } + + @Test("The far side of a wider item is dead, and holds the proposal") + func deadRegionHolds() { + for x: CGFloat in [213, 300, 420, 431] { + #expect(slot(x, current: 0) == nil, "dead region at \(x) must hold, not re-propose") + #expect(slot(x, current: 2) == nil) + #expect(slot(x, current: 3) == nil) + } + // Repeated evaluation in the dead region never moves the proposal. + var current = 0 + for _ in 0..<10 { current = slot(300, current: current) ?? current } + #expect(current == 0) + } + + @Test("A dead region with no valid prior proposal snaps to the containing zone") + func freshEntryFallsBackToTheContainingZone() { + // A drag in flight over a live target must always have some landing spot — the fresh + // cross-board entry, and the first sample after a reload invalidated the last proposal. + #expect(slot(300, current: nil) == 1) + #expect(slot(300, current: 99) == 1) + #expect(slot(300, current: -1) == 1) + } + + @Test("A dragged run at least as large as the item it crosses behaves uncapped") + func wideRunIsUncapped() { + for (x, expected): (CGFloat, Int) in [(50, 0), (300, 1), (420, 1), (500, 2), (600, 3)] { + #expect(slot(x, current: 0, span: 320) == expected, "cursor \(x)") + } + } + + @Test("The terminal slots are never capped") + func terminalSlotsAreUncapped() { + #expect(slot(-50, current: 2) == 0, "before the first item, slot 0 is the only reading") + #expect(slot(600, current: 0) == 3, "past the last item, appending is the only reading") + #expect(slot(10_000, current: nil) == 3) + } + + @Test("The exact-boundary tie survives the cap") + func boundaryTieStillHolds() { + #expect(slot(104, current: 0) == 0) + #expect(slot(104, current: 1) == 1) + } + + @Test("A multi-drag's span includes the gaps between its members") + func multiDragSpanIncludesInnerGaps() { + // Two 1× lanes dragged together: span = 100 + 8 + 100 = 208, so the wide lane's trigger + // stretches to 104 + 208 + 8 = 320. + #expect(slot(300, current: 0, span: 208) == 1) + #expect(slot(321, current: 0, span: 208) == nil, "beyond the run's footprint is still dead") + } + + @Test("An empty container is always index zero") + func emptyContainer() { + #expect(DropSlotMath.slot(cursor: 42, extents: [], gap: gap, draggedSpan: 100, current: nil) == 0) + } +} + +// MARK: - The lane strip + +/// `standard = 100`, `gap = 10`, matching `LaneReorderMathTests`: a 1× slot is 100 wide, a 2× is +/// 210 and a 3× is 320, and the strip's outer margin is one gap, so the first slot starts at 10. +@Suite("DropSlotMath ▸ the lane strip") +struct LaneSlotTests { + private let standard: CGFloat = 100 + private let gap: CGFloat = 10 + + @Test("The resting extents are LaneLayoutMath's own arithmetic, in range form") + func restingExtents() { + let extents = DropSlotMath.laneExtents(unitCounts: [1, 3, 1], standard: standard, gap: gap) + #expect(extents == [10...110, 120...440, 450...550]) + #expect(DropSlotMath.laneExtents(unitCounts: [], standard: standard, gap: gap).isEmpty) + + // The centres agree with `LaneReorderMath.centre`, which reads the same layout — the two + // must never drift, since the drag's replica offsets from one and its proposal from the + // other. + for index in 0..<3 { + let centre = LaneReorderMath.centre(ofLaneAt: index, unitCounts: [1, 3, 1], + standard: standard, gap: gap) + #expect(centre == (extents[index].lowerBound + extents[index].upperBound) / 2) + } + } + + @Test("A dragged run's span is its slots plus the gaps between them") + func runSpan() { + #expect(DropSlotMath.laneRunSpan(unitCounts: [], standard: standard, gap: gap) == 0) + #expect(DropSlotMath.laneRunSpan(unitCounts: [1], standard: standard, gap: gap) == 100) + #expect(DropSlotMath.laneRunSpan(unitCounts: [3], standard: standard, gap: gap) == 320) + #expect(DropSlotMath.laneRunSpan(unitCounts: [1, 1], standard: standard, gap: gap) == 210) + #expect(DropSlotMath.laneRunSpan(unitCounts: [1, 2, 1], standard: standard, gap: gap) == 430) + } + + @Test("A 1× lane crossing a 3× lane does not reflow until it reaches where it would land") + func widthAwareTriggers() { + // Remaining lanes [1, 3, 1]; dragging a 1× lane. Slot 1's trigger runs from 115 (the wide + // lane's leading edge less half a gap) for 100 + 10 → 225. (225, 445) is dead. + func slot(_ x: CGFloat, current: Int?) -> Int? { + DropSlotMath.laneSlot(cursorX: x, restingUnits: [1, 3, 1], draggedUnits: [1], + standard: standard, gap: gap, current: current) + } + #expect(slot(130, current: 0) == 1, "the wide lane's leading edge is where the drop lands") + #expect(slot(225, current: 0) == 1, "the cap's far edge still triggers") + #expect(slot(300, current: 0) == nil, "the wide lane's far side holds the proposal") + #expect(slot(300, current: nil) == 1, "with nothing to hold, the containing zone answers") + #expect(slot(500, current: 0) == 2) + #expect(slot(600, current: 0) == 3, "past the last lane: the end slot, uncapped") + #expect(slot(-100, current: 2) == 0, "before the first lane: slot 0, uncapped") + } + + @Test("A wide dragged run reaches further, and a run of two reaches further still") + func runSpanWidensTheTrigger() { + // Dragging a 3× lane (span 320): the cap covers the whole of the wide lane's zone. + #expect(DropSlotMath.laneSlot(cursorX: 300, restingUnits: [1, 3, 1], draggedUnits: [3], + standard: standard, gap: gap, current: 0) == 1) + // Two 1× lanes together (span 210): the trigger reaches 115 + 210 + 10 = 335. + #expect(DropSlotMath.laneSlot(cursorX: 330, restingUnits: [1, 3, 1], draggedUnits: [1, 1], + standard: standard, gap: gap, current: 0) == 1) + #expect(DropSlotMath.laneSlot(cursorX: 340, restingUnits: [1, 3, 1], draggedUnits: [1, 1], + standard: standard, gap: gap, current: 0) == nil) + } + + @Test("An empty strip proposes slot zero") + func emptyStrip() { + #expect(DropSlotMath.laneSlot(cursorX: 200, restingUnits: [], draggedUnits: [1], + standard: standard, gap: gap, current: nil) == 0) + } +} + +// MARK: - The masonry's resting grid + +@Suite("MasonryPlacement") +struct MasonryPlacementTests { + private let placement = MasonryPlacement(columnCount: 2, columnWidth: 100, spacing: 8) + + @Test("Children are assigned round-robin and each column stacks independently") + func roundRobinStacking() { + let frames = placement.frames(heights: [40, 60, 30, 20, 50]) + #expect(frames == [ + CGRect(x: 0, y: 0, width: 100, height: 40), // column 0, row 0 + CGRect(x: 108, y: 0, width: 100, height: 60), // column 1, row 0 + CGRect(x: 0, y: 48, width: 100, height: 30), // column 0, row 1 — under card 0 only + CGRect(x: 108, y: 68, width: 100, height: 20), // column 1, row 1 — under card 1 only + CGRect(x: 0, y: 86, width: 100, height: 50), + ]) + } + + @Test("The placement matches an independent reading of the documented rule") + func differentialAgainstTheStatedRule() { + // A second implementation of the rule as 03-board-ui.md states it — "child `i` → column + // `i % columns`, each column stacks top-aligned and independently" — written from the + // words rather than from the code. `MasonryLayout` places subviews through + // `MasonryPlacement`, so agreeing here is agreeing with what is drawn. + func naive(_ heights: [CGFloat], columns: Int, width: CGFloat, spacing: CGFloat, + origin: CGPoint) -> [CGRect] { + var stacks = [[CGFloat]](repeating: [], count: columns) + var frames: [CGRect] = [] + for (index, height) in heights.enumerated() { + let column = index % columns + let stacked = stacks[column].reduce(0) { $0 + $1 + spacing } + frames.append(CGRect(x: origin.x + CGFloat(column) * (width + spacing), + y: origin.y + stacked, + width: width, height: height)) + stacks[column].append(height) + } + return frames + } + + let heights: [CGFloat] = [40, 60, 30, 20, 50, 55, 12] + for columns in 1...4 { + let origin = CGPoint(x: 17, y: 23) + let placement = MasonryPlacement(columnCount: columns, columnWidth: 100, + spacing: 8, origin: origin) + #expect(placement.frames(heights: heights) + == naive(heights, columns: columns, width: 100, spacing: 8, origin: origin), + "\(columns) interior columns") + } + } + + @Test("The reported height is the tallest column's stack") + func heightIsTheTallestColumn() { + let heights: [CGFloat] = [40, 60, 30, 20, 50] + let frames = placement.frames(heights: heights) + // Column 0 stacks 40 + 8 + 30 + 8 + 50 = 136; column 1 stacks 60 + 8 + 20 = 88. + #expect(placement.height(heights: heights) == 136) + #expect(placement.height(heights: heights) == frames.map(\.maxY).max()) + #expect(placement.height(heights: []) == 0) + #expect(placement.frames(heights: []).isEmpty) + } + + @Test("Column and row invert to the logical index") + func columnRowInversion() { + for index in 0..<9 { + #expect(placement.index(column: placement.column(of: index), + row: placement.row(of: index)) == index) + } + #expect(placement.column(of: 3) == 1) + #expect(placement.row(of: 3) == 1) + } + + @Test("Column width divides the lane, and a degenerate column count clamps to one") + func columnWidthArithmetic() { + #expect(MasonryPlacement.columnWidth(totalWidth: 316, columnCount: 3, spacing: 8) == 100) + #expect(MasonryPlacement.columnWidth(totalWidth: 100, columnCount: 1, spacing: 8) == 100) + // A lane narrower than its own spacings never proposes a negative width. + #expect(MasonryPlacement.columnWidth(totalWidth: 4, columnCount: 3, spacing: 8) == 0) + #expect(MasonryPlacement(columnCount: 0, columnWidth: 100, spacing: 8).columnCount == 1) + } +} + +// MARK: - The masonry's insertion index + +/// A 2-wide lane of five cards, 100pt columns and 8pt spacing: +/// column 0 (x 0…100): card 0 [0, 40] · card 2 [48, 78] · card 4 [86, 136] +/// column 1 (x 108…208): card 1 [0, 60] · card 3 [68, 88] +/// Column bands meet at 104. Column 0's zone boundaries are 44, 82, 140; column 1's are 64, 92. +@Suite("DropSlotMath ▸ the card masonry") +struct CardSlotTests { + private let placement = MasonryPlacement(columnCount: 2, columnWidth: 100, spacing: 8) + private let heights: [CGFloat] = [40, 60, 30, 20, 50] + + private func slot(_ x: CGFloat, _ y: CGFloat, current: Int?, dragged: CGFloat = 30) -> Int? { + DropSlotMath.cardSlot(cursor: CGPoint(x: x, y: y), placement: placement, + heights: heights, draggedHeight: dragged, current: current) + } + + @Test("A cursor over a card claims that card's logical position") + func cursorOverACardClaimsItsLogicalPosition() { + let probes: [(CGFloat, CGFloat, Int)] = [ + (20, 20, 0), (150, 20, 1), (20, 60, 2), (150, 75, 3), (20, 100, 4), + ] + for (x, y, expected) in probes { + for current in [nil, 0, 1, 2, 3, 4, 5] { + #expect(slot(x, y, current: current) == expected, + "(\(x), \(y)) should claim slot \(expected) (current \(String(describing: current)))") + } + } + } + + @Test("Column, then row, then r · C + c — the round-robin inverse") + func columnAndRowComposeTheIndex() { + // Column 1's second row is logical position 3, not "the fourth thing the cursor passed": + // the index is the lane's card order, which is what the store writes and what VoiceOver + // traverses (10-accessibility.md's logical-order rule). + #expect(slot(150, 75, current: nil) == 3) + #expect(placement.column(of: 3) == 1) + #expect(placement.row(of: 3) == 1) + } + + @Test("Below any column's last card is the end slot") + func belowAColumnIsTheEndSlot() { + #expect(slot(20, 200, current: nil) == 5, "below column 0 — clamped past the end") + #expect(slot(150, 200, current: nil) == 5, "below column 1 — exactly the end") + #expect(slot(20, 200, current: 1) == 5) + } + + @Test("Above and beside the grid clamp inward to the nearest column") + func clampingAtTheEdges() { + #expect(slot(20, -40, current: nil) == 0, "the lane header targets the first row") + #expect(slot(150, -40, current: nil) == 1) + #expect(slot(-60, 20, current: nil) == 0, "the lane's leading padding is still column 0") + #expect(slot(400, 20, current: nil) == 1, "and its trailing padding column 1") + } + + @Test("A dead region below a tall card holds the proposal") + func deadRegionHolds() { + // Dragging a 10pt card: slot 4's trigger runs from 82 for 10 + 8 → 100, so (100, 140) is + // the far side of card 4's zone and changes nothing. + #expect(slot(20, 95, current: 0, dragged: 10) == 4, "inside the footprint, the slot triggers") + #expect(slot(20, 120, current: 0, dragged: 10) == nil) + #expect(slot(20, 120, current: 2, dragged: 10) == nil) + // With nothing to hold, the containing zone answers — a drag in flight has a landing spot. + #expect(slot(20, 120, current: nil, dragged: 10) == 4) + + var current = 0 + for _ in 0..<10 { current = slot(20, 120, current: current, dragged: 10) ?? current } + #expect(current == 0) + } + + @Test("A cursor on a zone boundary keeps whichever adjoining slot is proposed") + func boundaryTie() { + // y = 44 is the boundary between column 0's slots 0 and 1. A 60pt dragged card reaches + // past it from either side, so the cap does not decide and the tie rule does. + #expect(slot(20, 44, current: 0, dragged: 60) == 0) + #expect(slot(20, 44, current: 2, dragged: 60) == 2, "slot 2 is column 0's row 1") + var index = 0 + for _ in 0..<10 { index = slot(20, 44, current: index, dragged: 60) ?? index } + #expect(index == 0, "the boundary pixel is a fixed point") + } + + @Test("Re-evaluating a resting hover is a fixed point — own-slot pickup never reflows") + func ownSlotPickupIsANoOp() { + var index = 2 + for _ in 0..<10 { index = slot(20, 60, current: index) ?? index } + #expect(index == 2) + } + + @Test("A proposal in another column never holds this one") + func aProposalInAnotherColumnDoesNotHold() { + // Slot 1 lives in column 1; a cursor deep in column 0's dead region cannot "hold" it, + // because holding a proposal the cursor is nowhere near would strand the shadow. + #expect(slot(20, 120, current: 1, dragged: 10) == 4) + } + + @Test("A one-column lane behaves like a plain vertical list") + func oneColumnLane() { + let column = MasonryPlacement(columnCount: 1, columnWidth: 200, spacing: 8) + func slot(_ y: CGFloat, current: Int?) -> Int? { + DropSlotMath.cardSlot(cursor: CGPoint(x: 100, y: y), placement: column, + heights: [40, 40], draggedHeight: 40, current: current) + } + #expect(slot(20, current: nil) == 0) + #expect(slot(60, current: nil) == 1) + #expect(slot(120, current: nil) == 2) + #expect(slot(44, current: 0) == 0) + #expect(slot(44, current: 1) == 1) + } + + @Test("An empty lane proposes slot zero, and a lane with fewer cards than columns still appends") + func degenerateGrids() { + #expect(DropSlotMath.cardSlot(cursor: CGPoint(x: 10, y: 10), placement: placement, + heights: [], draggedHeight: 30, current: nil) == 0) + // One card, two columns: column 1 is empty, and its only slot is the end. + #expect(DropSlotMath.cardSlot(cursor: CGPoint(x: 150, y: 10), placement: placement, + heights: [40], draggedHeight: 30, current: nil) == 1) + } +} + +// MARK: - Applying a proposal + +@Suite("DropSlotMath ▸ applying a proposal") +struct AppliedTests { + + @Test("A run lifts out and re-inserts contiguously, in the order it was given") + func contiguousInsertPreservesOrder() { + let items = ["a", "b", "c", "d", "e"] + #expect(DropSlotMath.applied(items, moving: ["b", "d"], to: 0) == ["b", "d", "a", "c", "e"]) + #expect(DropSlotMath.applied(items, moving: ["b", "d"], to: 3) == ["a", "c", "e", "b", "d"]) + #expect(DropSlotMath.applied(items, moving: ["d", "b"], to: 1) == ["a", "d", "b", "c", "e"], + "the run's own order is preserved, not re-derived") + } + + @Test("An index counted with the run removed makes the resting position a no-op") + func ownSlotIsIdentity() { + let items = ["a", "b", "c"] + #expect(DropSlotMath.applied(items, moving: ["b"], to: 1) == items) + } + + @Test("Out-of-range indices clamp rather than trap") + func clamping() { + let items = ["a", "b", "c"] + #expect(DropSlotMath.applied(items, moving: ["a"], to: -5) == ["a", "b", "c"]) + #expect(DropSlotMath.applied(items, moving: ["a"], to: 99) == ["b", "c", "a"]) + #expect(DropSlotMath.applied(items, moving: [], to: 1) == items) + } +} diff --git a/KanbanTests/RanksTests.swift b/KanbanTests/RanksTests.swift index 0f11f00..9662cd0 100644 --- a/KanbanTests/RanksTests.swift +++ b/KanbanTests/RanksTests.swift @@ -165,6 +165,53 @@ struct RanksTests { #expect(Ranks.insertionRank(amongVisible: [1024, 1024], at: 2) == 2048) } + // MARK: - A contiguous run's ranks (multi-drag) + + @Test func insertionRanksPlaceARunAtOneSpotInOrder() throws { + let orders = [1024.0, 2048.0, 3072.0] + + // Between two siblings: `count` evenly spaced points, strictly inside and ascending. + let interior = try #require(Ranks.insertionRanks(amongVisible: orders, at: 1, count: 3)) + #expect(interior == [1280, 1536, 1792]) + #expect(interior.first! > orders[0] && interior.last! < orders[1]) + #expect(zip(interior, interior.dropFirst()).allSatisfy { $0 < $1 }) + + // The ends spread whole gaps, ascending, so the run lands as a block. + #expect(Ranks.insertionRanks(amongVisible: orders, at: 3, count: 2) == [4096, 5120]) + #expect(Ranks.insertionRanks(amongVisible: orders, at: 0, count: 2) == [-1024, 0]) + } + + @Test func insertionRanksAgreeWithTheSingleRankTwinForOneItem() { + let orders = [1024.0, 2048.0, 3072.0] + for index in -1...4 { + #expect(Ranks.insertionRanks(amongVisible: orders, at: index, count: 1) + == Ranks.insertionRank(amongVisible: orders, at: index).map { [$0] }, + "position \(index)") + } + } + + @Test func insertionRanksAreTotalOnEdgeInputs() { + #expect(Ranks.insertionRanks(amongVisible: [], at: 0, count: 3) == [1024, 2048, 3072]) + #expect(Ranks.insertionRanks(amongVisible: [1024], at: 99, count: 2) == [2048, 3072]) + #expect(Ranks.insertionRanks(amongVisible: [1024], at: -3, count: 2) == [-1024, 0]) + // A run of nothing is nothing, not a failure: a drag emptied by a foreign reload cancels + // itself, and the write it would have made is simply empty. + #expect(Ranks.insertionRanks(amongVisible: [1024], at: 0, count: 0) == []) + } + + @Test func insertionRanksReportAnExhaustedGapRatherThanInventingOne() { + // The duplicate-order tie and adjacent Doubles are both renumber triggers, exactly as for + // the single-rank twin — and a gap that fits one rank need not fit three. + #expect(Ranks.insertionRanks(amongVisible: [1024, 1024], at: 1, count: 2) == nil) + #expect(Ranks.insertionRanks(amongVisible: [1024, 1024.0000000000002], at: 1, count: 1) == nil) + let tight = [1.0, 1.0.nextUp.nextUp] + #expect(Ranks.insertionRanks(amongVisible: tight, at: 1, count: 1) != nil) + #expect(Ranks.insertionRanks(amongVisible: tight, at: 1, count: 4) == nil) + + // The ends never exhaust. + #expect(Ranks.insertionRanks(amongVisible: [1024, 1024], at: 2, count: 2) == [2048, 3072]) + } + // MARK: - Precision exhaustion → renumber, deterministically @Test func precisionExhaustionThenRenumberIsDeterministic() { diff --git a/KanbanTests/TrashWriteTests.swift b/KanbanTests/TrashWriteTests.swift index 3a3d43b..7b6bbc2 100644 --- a/KanbanTests/TrashWriteTests.swift +++ b/KanbanTests/TrashWriteTests.swift @@ -369,13 +369,15 @@ struct TrashDragRestoreTests { let store = try BoardStore(rootURL: fixture.root) let original = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)") - store.restoreByDrag(cardID: card2, intoLane: lane1) + store.restoreByDrag(cardID: card2, intoLane: lane1, at: 1) let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)") #expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)")) #expect(!after.contains("deleted")) - // The `order` is untouched, so the card returns where it was rather than at the bottom — - // the position-perfect restore a pure-view trash makes possible. + // Dropped at index 1 — past lane one's single live card, which is exactly where the card's + // recorded 2048 already puts it. The rank the drop names and the rank on disk agree, so no + // `order` is written at all: the `order` line comes through byte-for-byte and the card + // returns where it was, the position-perfect restore a pure-view trash makes possible. #expect(try FrontmatterDocument.parse(after).order == .valid(2048)) #expect(untouchedLines(after) == untouchedLines(original)) } @@ -386,14 +388,14 @@ struct TrashDragRestoreTests { defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - store.restoreByDrag(cardID: card2, intoLane: lane2) + store.restoreByDrag(cardID: card2, intoLane: lane2, at: 1) #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)")) #expect(fixture.exists("\(Ident.lane2)/\(Ident.card2)")) let after = try fixture.indexText("\(Ident.lane2)/\(Ident.card2)") #expect(!after.contains("deleted")) - // m5-drag: the positional drop replaces this append. Lane two's one visible card is at - // 1024, so the arrival lands at 2048 — the Writer's own append over visible siblings. + // Dropped at index 1 — lane two's one visible card is at 1024, so the drop's own rank is + // the append 2048, carried by the move rather than left to the Writer to compute. #expect(try FrontmatterDocument.parse(after).order == .valid(2048)) let model = try BoardLoader.load(boardRoot: fixture.root).model @@ -413,13 +415,13 @@ struct TrashDragRestoreTests { // A tombstoned destination lane is never a drop target (04 ▸ Drag and drop: "a card is // never filed under a `deleted:` parent"). - store.restoreByDrag(cardID: card2, intoLane: lane3) + store.restoreByDrag(cardID: card2, intoLane: lane3, at: 0) // A lane that is not on the board at all. - store.restoreByDrag(cardID: card2, intoLane: ItemID(rawValue: Ident.indexless)) + store.restoreByDrag(cardID: card2, intoLane: ItemID(rawValue: Ident.indexless), at: 0) // A card that is not a trash row: live, and — for `card4` — hidden by its lane rather than // by its own flag, so it has no row to drag in the first place. - store.restoreByDrag(cardID: card1, intoLane: lane2) - store.restoreByDrag(cardID: card4, intoLane: lane1) + store.restoreByDrag(cardID: card1, intoLane: lane2, at: 0) + store.restoreByDrag(cardID: card4, intoLane: lane1, at: 0) #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card2)").modified == trashedCard.modified) #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)").modified == liveCard.modified) @@ -434,7 +436,7 @@ struct TrashDragRestoreTests { let store = try BoardStore(rootURL: fixture.root) store.enterVanishedRootLock() - store.restoreByDrag(cardID: card2, intoLane: lane2) + store.restoreByDrag(cardID: card2, intoLane: lane2, at: 1) #expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)")) #expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)").contains("deleted:"))