The pickup seeds its own slot — the lift and the shadow become one transaction

"At drag start it replaces the item's original space" is now true from the
very first frame: begin takes the run's own resting slot as a seed, set in
the same transaction that lifts the run out. Without it the vacated gap
closed un-animated and sprang back open at the first dropUpdated — a
shuffle carrying no information. The seed bypasses propose deliberately (a
pickup is not a new landing spot, so no alignment tick), and the first real
sample's re-propose of the same slot is the early-out's ordinary silence. A
⌥-pickup seeds nothing — a copy's resting layout keeps the originals in
place, so there is no vacated space to hold. Card pickups compute the seed
through the same LaneView.rendered rules the retargets count in; lane
pickups seed their strip slot the same way. DRAG-REORDER.md's own-slot
paragraph records the rule, and SeededPickupTests pins all four corners.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-07 11:22:26 -04:00
parent eef0a4539f
commit f126614b56
5 changed files with 176 additions and 9 deletions
+29 -1
View File
@@ -408,11 +408,39 @@ struct CardFaceView: View, Equatable {
// span-cap (03-board-ui.md § Motion).
heights: ordered.map { drops.registry.heights[$0] ?? drops.registry.nominalCardHeight },
container: .board,
source: store
source: store,
seed: ownSlotSeed(dragging: ids, laneID: lanesByCard[card.id], in: snapshot)
)
return payload.itemProvider()
}
/// The pickup's own-slot proposal (`DragSession.begin`): the grabbed card's position among its
/// lane's rendered cards with the dragged run lifted out the same resting-layout index space
/// the retargets and `moveCards` count in, filtered by the same `LaneView.rendered` rules, so
/// the seeded shadow run stands exactly where the lifted cards stood and pickup moves nothing.
///
/// **A -pickup seeds nothing.** A copy's resting layout keeps the originals in place, so there
/// is no vacated space for a shadow to hold; the first `dropUpdated` resolves the copy and opens
/// the run beside the originals, exactly as it does today (DRAG-REORDER.md § Resting-layout
/// zones the modifier reflow is the feedback being asked for).
private func ownSlotSeed(
dragging ids: Set<ItemID>,
laneID: ItemID?,
in snapshot: BoardModel
) -> DropTarget? {
guard !NSEvent.modifierFlags.contains(.option),
let laneID,
let laneCards = snapshot.lanes.first(where: { $0.id == laneID })?.cards
else { return nil }
let index = LaneView.rendered(
Array(laneCards.prefix { $0.id != card.id }),
hiddenByDrag: ids,
filter: store.searchFilter,
renaming: store.transient.renameEditor?.targetID
).count
return DropTarget(boardRoot: store.rootKey, container: .lane(laneID), index: index)
}
/// A trash card's drag out **the restore**, and deliberately not special: an ordinary `.cards`
/// session in the `.trash` container, which `BoardDropContext.commitDrop` hands to the same
/// `moveCards`/`copyCards`/`receiveCards` every board card uses. "Restoring is an ordinary move
+22 -6
View File
@@ -611,17 +611,19 @@ final class DragSession {
/// - members: the dragged cards in flatten order (`SelectionGrammar.boardCards`, or the
/// trash's own order for a trash-card drag).
/// - heights: their measured heights, captured **before** the pickup transition starts.
/// - seed: the run's own resting slot, proposed from the first frame see `begin`.
func beginCards(
_ members: [ItemID],
folders: [URL],
heights: [CGFloat],
container: ItemContainer,
source: BoardStore,
mixesKinds: Bool = false
mixesKinds: Bool = false,
seed: DropTarget? = nil
) {
begin(
kind: .cards, members: members, folders: folders,
container: container, source: source, mixesKinds: mixesKinds
container: container, source: source, mixesKinds: mixesKinds, seed: seed
)
cardHeights = heights
laneUnits = []
@@ -637,23 +639,37 @@ final class DragSession {
units: [Int],
container: ItemContainer = .board,
source: BoardStore,
mixesKinds: Bool = false
mixesKinds: Bool = false,
seed: DropTarget? = nil
) {
begin(
kind: .lanes, members: members, folders: folders,
container: container, source: source, mixesKinds: mixesKinds
container: container, source: source, mixesKinds: mixesKinds, seed: seed
)
laneUnits = units
cardHeights = []
}
/// `seed` is the pickup's own-slot proposal "at drag start it replaces the item's original
/// space" (DRAG-REORDER.md § The pieces), made true from the very first frame rather than from
/// the first `dropUpdated`. Without it the lift-out and the shadow's arrival land in different
/// transactions: the masonry closes the vacated gap (un-animated `shadowRun` hasn't moved) and
/// then springs it back open when the first hover sample proposes the own slot, a shuffle for no
/// information. Seeded, the lift and the shadow are one transaction with identical geometry
/// the run's frozen sizes at the run's own resting position so nothing on the board moves at
/// pickup, which is what the own-slot fixed point always promised.
///
/// Set directly rather than through `propose`, because a pickup is not a *new landing spot*:
/// the alignment tick stays reserved for genuine retargets. The first real `dropUpdated`
/// re-proposes the same slot and `propose`'s early-out makes it a silent no-op.
private func begin(
kind: DragKind,
members: [ItemID],
folders: [URL],
container: ItemContainer,
source: BoardStore,
mixesKinds: Bool
mixesKinds: Bool,
seed: DropTarget?
) {
// A new drag's first `sinceLastMs` must not be the gap since the previous drag's last sample.
#if DEBUG
@@ -669,7 +685,7 @@ final class DragSession {
self.mixesKinds = mixesKinds
self.sourceStore = source
self.sourceRoot = source.rootKey
self.proposal = nil
self.proposal = seed
self.operation = .move
// The reload-resolved drag set: vanished members leave it silently, which is what
// `survivors` reads and what "an emptied drag cancels itself" is stated in terms of.
+11 -1
View File
@@ -587,7 +587,17 @@ struct LaneView: View, Equatable {
// The dragged items' own sizes, frozen at drag start the one thing that is
// (03-board-ui.md § Motion).
units: members.map { LaneLayoutMath.displayUnits(of: $0) },
source: store
source: store,
// The strip's own-slot seed (`DragSession.begin`): the grabbed lane's position among
// the lanes with the dragged run removed the strip proposal's own index space. A
// within-board lane drag never resolves to `.copy` ( is ignored there), so unlike the
// card seed this one carries no modifier clause.
seed: DropTarget(
boardRoot: store.rootKey,
container: .strip,
index: store.snapshot.lanes.prefix { $0.id != lane.id }
.count { !ids.contains($0.id) }
)
)
return payload.itemProvider()
}