Build the drop-slot model and the drop commits — drag & drop, first half
The pathfinder's drag-reorder model, ported and generalized (DRAG-REORDER.md travels with it, rewritten for lanes, the interior masonry, multi-drag, cross-board sessions, the re-grounding trio, and the committed-overlay hold): - DropSlotMath — resting-layout zones from analytic lane arithmetic and the pure masonry placement (MasonryLayout now lays out through the same MasonryPlacement the drag reads, so geometry cannot drift), span-capped triggers sized to the dragged run's future footprint, hysteresis holds with the fresh-entry fallback, boundary ties, own-slot no-ops; nil means hold. - DragAutoScrollMath — the activation bands and velocity ramp, pure. - The drop commits, one performWrite bracket each: moveCards/copyCards within a board (insertion ranks touch only the dragged cards; renumber fallback); receiveCards/receiveLanes/receiveRestoredCards on the destination store for cross-board copy and ⌘-move with the import-boundary remint, lane copies stripping tombstoned cards while moves carry them; restoreByDrag is now positional, writing order only when the drop names a new one. Gestures, sessions, previews, and delegates are the second half. 773 unit tests (87 new since the keyboard grammar). Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -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<ItemID>) -> [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<ItemID>, 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<ItemID>, 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 (`<root>/<lane>` and `<root>/<lane>/<card>`), 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: <now>` into its own `index.md` —
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<CGFloat>], gap: CGFloat) -> [CGFloat] {
|
||||
guard !extents.isEmpty else { return [] }
|
||||
var boundaries: [CGFloat] = []
|
||||
for index in 0..<(extents.count - 1) {
|
||||
boundaries.append((extents[index].upperBound + extents[index + 1].lowerBound) / 2)
|
||||
}
|
||||
boundaries.append(extents[extents.count - 1].upperBound + gap / 2)
|
||||
return boundaries
|
||||
}
|
||||
|
||||
/// The slot (`0...boundaries.count`) whose zone contains `cursor` — the uncapped reading,
|
||||
/// before any span cap applies.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - cursor: pointer position along the layout axis, in `boundaries`' coordinate space.
|
||||
/// - boundaries: `zoneBoundaries(extents:gap:)`, ascending.
|
||||
/// - current: the currently proposed slot (`nil` when there is none). Consulted **only** to
|
||||
/// break the tie when `cursor` sits on an exact boundary value: if `current` is one of the
|
||||
/// two zones meeting there it is kept, so the shadow can never oscillate on a boundary
|
||||
/// pixel.
|
||||
static func containingSlot(cursor: CGFloat, boundaries: [CGFloat], current: Int?) -> Int {
|
||||
let count = boundaries.count
|
||||
guard count > 0 else { return 0 }
|
||||
|
||||
// Exact-boundary tie: the zones meeting at `cursor` are `b` and `b + 1`; keep the current
|
||||
// proposal if it is one of them.
|
||||
if let current,
|
||||
let boundaryIndex = boundaries.firstIndex(of: cursor),
|
||||
current == boundaryIndex || current == boundaryIndex + 1 {
|
||||
return current
|
||||
}
|
||||
|
||||
// Otherwise the containing zone: how many boundaries sit at or below the cursor (a zone is
|
||||
// entered exactly at its border).
|
||||
var index = 0
|
||||
while index < count, cursor >= boundaries[index] { index += 1 }
|
||||
return index
|
||||
}
|
||||
|
||||
/// The span-capped slot for `cursor`, or `nil` to **hold** the current proposal.
|
||||
///
|
||||
/// Slot `i` triggers only while the cursor is over `[leading(i) − gap/2, leading(i) +
|
||||
/// draggedSpan + gap/2]` — where the dragged run would sit after a drop there. Past that the
|
||||
/// zone is dead and this answers `nil`, so dragging a 1× lane across a 3× lane does not reflow
|
||||
/// while the cursor is over the 3× lane's far side; the shadow stays where it was until the
|
||||
/// cursor reaches a spot the run could really land.
|
||||
///
|
||||
/// Two slots are never capped: the **end slot** (past the last item — appending is the only
|
||||
/// reading) and, by construction rather than by a special case, the region **before the first
|
||||
/// item** (the cap only ever truncates a zone's far side, and slot 0's far side is inside the
|
||||
/// container).
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - cursor: pointer position along the layout axis.
|
||||
/// - extents: the visible items' resting spans with the dragged run removed, ascending.
|
||||
/// - gap: the layout's inter-item spacing.
|
||||
/// - draggedSpan: the dragged run's total extent when laid out — the sum of its items' spans
|
||||
/// plus the gaps between them.
|
||||
/// - current: the currently proposed slot, or `nil`. A dead region with no valid `current`
|
||||
/// proposes the containing slot (the fresh-entry rule); with one, it answers `nil`.
|
||||
/// - Returns: a slot in `0...extents.count`, or `nil` meaning "no change".
|
||||
static func slot(
|
||||
cursor: CGFloat,
|
||||
extents: [ClosedRange<CGFloat>],
|
||||
gap: CGFloat,
|
||||
draggedSpan: CGFloat,
|
||||
current: Int?
|
||||
) -> Int? {
|
||||
guard !extents.isEmpty else { return 0 }
|
||||
let boundaries = zoneBoundaries(extents: extents, gap: gap)
|
||||
let index = containingSlot(cursor: cursor, boundaries: boundaries, current: current)
|
||||
guard index < extents.count else { return index } // end slot: uncapped
|
||||
|
||||
let triggerStart = extents[index].lowerBound - gap / 2
|
||||
if cursor <= triggerStart + draggedSpan + gap { return index }
|
||||
|
||||
// Dead region. Hold — unless there is nothing to hold, in which case the containing zone
|
||||
// is the answer: a drag in flight must always have some landing spot.
|
||||
guard let current, (0...extents.count).contains(current) else { return index }
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - The lane strip
|
||||
|
||||
/// The lanes' resting extents along the strip, in strip coordinates (0 at the strip's leading
|
||||
/// edge, the outer margin included) — the layout `unitCounts` would have if it were the whole
|
||||
/// strip.
|
||||
///
|
||||
/// The strip's outer margin is one `gap`, so the first slot starts at `gap`; each lane is
|
||||
/// `LaneLayoutMath.slotWidth(units:standard:gap:)` wide and one `gap` follows it. Same
|
||||
/// arithmetic `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<CGFloat>] {
|
||||
var extents: [ClosedRange<CGFloat>] = []
|
||||
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..<placement.columnCount`, clamped, so the lane's
|
||||
/// padding and the region above its grid target the nearest column rather than nothing.
|
||||
///
|
||||
/// The bands tile: column `c` plus half a spacing on each side. `currentColumn` breaks an
|
||||
/// exact-boundary tie exactly as `containingSlot` does in 1D.
|
||||
static func columnIndex(atX x: CGFloat, placement: MasonryPlacement, currentColumn: Int?) -> Int {
|
||||
let count = placement.columnCount
|
||||
guard count > 1 else { return 0 }
|
||||
let boundaries = (0..<(count - 1)).map {
|
||||
placement.columnX($0) + placement.columnWidth + placement.spacing / 2
|
||||
}
|
||||
return containingSlot(cursor: x, boundaries: boundaries, current: currentColumn)
|
||||
}
|
||||
|
||||
/// Where a card drag would land in a lane's masonry: a position in the lane's **logical** card
|
||||
/// order (`0...heights.count`), or `nil` to hold the current proposal.
|
||||
///
|
||||
/// Cursor → proposal in three steps (DRAG-REORDER.md § The card masonry):
|
||||
///
|
||||
/// 1. **Column** — the cursor's x-band picks interior column `c`, clamped inward at the edges.
|
||||
/// 2. **Row** — column `c`'s cards are 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<T: Equatable>(_ items: [T], moving: [T], to index: Int) -> [T] {
|
||||
var remaining = items.filter { !moving.contains($0) }
|
||||
let target = min(max(0, index), remaining.count)
|
||||
remaining.insert(contentsOf: moving, at: target)
|
||||
return remaining
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user