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:
+1
-1
@@ -34,7 +34,7 @@ Lanes get this for free and the invariant is worth stating: a within-board lane
|
|||||||
Two stability rules on top:
|
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.
|
- **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.
|
- **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 own slot is also **seeded at `begin`** (`DragSession.begin`'s `seed`), in the same transaction that lifts the run out, so "at drag start it replaces the item's original space" is true from the very first frame: without the seed the vacated gap closes un-animated and springs back open at the first `dropUpdated` — a shuffle carrying no information. The seed bypasses `propose` (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, and the first sample answers as it always did.
|
||||||
|
|
||||||
## The lane strip's resting layout is arithmetic
|
## The lane strip's resting layout is arithmetic
|
||||||
|
|
||||||
|
|||||||
@@ -408,11 +408,39 @@ struct CardFaceView: View, Equatable {
|
|||||||
// span-cap (03-board-ui.md § Motion).
|
// span-cap (03-board-ui.md § Motion).
|
||||||
heights: ordered.map { drops.registry.heights[$0] ?? drops.registry.nominalCardHeight },
|
heights: ordered.map { drops.registry.heights[$0] ?? drops.registry.nominalCardHeight },
|
||||||
container: .board,
|
container: .board,
|
||||||
source: store
|
source: store,
|
||||||
|
seed: ownSlotSeed(dragging: ids, laneID: lanesByCard[card.id], in: snapshot)
|
||||||
)
|
)
|
||||||
return payload.itemProvider()
|
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`
|
/// 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
|
/// session in the `.trash` container, which `BoardDropContext.commitDrop` hands to the same
|
||||||
/// `moveCards`/`copyCards`/`receiveCards` every board card uses. "Restoring is an ordinary move
|
/// `moveCards`/`copyCards`/`receiveCards` every board card uses. "Restoring is an ordinary move
|
||||||
|
|||||||
@@ -611,17 +611,19 @@ final class DragSession {
|
|||||||
/// - members: the dragged cards in flatten order (`SelectionGrammar.boardCards`, or the
|
/// - members: the dragged cards in flatten order (`SelectionGrammar.boardCards`, or the
|
||||||
/// trash's own order for a trash-card drag).
|
/// trash's own order for a trash-card drag).
|
||||||
/// - heights: their measured heights, captured **before** the pickup transition starts.
|
/// - 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(
|
func beginCards(
|
||||||
_ members: [ItemID],
|
_ members: [ItemID],
|
||||||
folders: [URL],
|
folders: [URL],
|
||||||
heights: [CGFloat],
|
heights: [CGFloat],
|
||||||
container: ItemContainer,
|
container: ItemContainer,
|
||||||
source: BoardStore,
|
source: BoardStore,
|
||||||
mixesKinds: Bool = false
|
mixesKinds: Bool = false,
|
||||||
|
seed: DropTarget? = nil
|
||||||
) {
|
) {
|
||||||
begin(
|
begin(
|
||||||
kind: .cards, members: members, folders: folders,
|
kind: .cards, members: members, folders: folders,
|
||||||
container: container, source: source, mixesKinds: mixesKinds
|
container: container, source: source, mixesKinds: mixesKinds, seed: seed
|
||||||
)
|
)
|
||||||
cardHeights = heights
|
cardHeights = heights
|
||||||
laneUnits = []
|
laneUnits = []
|
||||||
@@ -637,23 +639,37 @@ final class DragSession {
|
|||||||
units: [Int],
|
units: [Int],
|
||||||
container: ItemContainer = .board,
|
container: ItemContainer = .board,
|
||||||
source: BoardStore,
|
source: BoardStore,
|
||||||
mixesKinds: Bool = false
|
mixesKinds: Bool = false,
|
||||||
|
seed: DropTarget? = nil
|
||||||
) {
|
) {
|
||||||
begin(
|
begin(
|
||||||
kind: .lanes, members: members, folders: folders,
|
kind: .lanes, members: members, folders: folders,
|
||||||
container: container, source: source, mixesKinds: mixesKinds
|
container: container, source: source, mixesKinds: mixesKinds, seed: seed
|
||||||
)
|
)
|
||||||
laneUnits = units
|
laneUnits = units
|
||||||
cardHeights = []
|
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(
|
private func begin(
|
||||||
kind: DragKind,
|
kind: DragKind,
|
||||||
members: [ItemID],
|
members: [ItemID],
|
||||||
folders: [URL],
|
folders: [URL],
|
||||||
container: ItemContainer,
|
container: ItemContainer,
|
||||||
source: BoardStore,
|
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.
|
// A new drag's first `sinceLastMs` must not be the gap since the previous drag's last sample.
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
@@ -669,7 +685,7 @@ final class DragSession {
|
|||||||
self.mixesKinds = mixesKinds
|
self.mixesKinds = mixesKinds
|
||||||
self.sourceStore = source
|
self.sourceStore = source
|
||||||
self.sourceRoot = source.rootKey
|
self.sourceRoot = source.rootKey
|
||||||
self.proposal = nil
|
self.proposal = seed
|
||||||
self.operation = .move
|
self.operation = .move
|
||||||
// The reload-resolved drag set: vanished members leave it silently, which is what
|
// 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.
|
// `survivors` reads and what "an emptied drag cancels itself" is stated in terms of.
|
||||||
|
|||||||
@@ -587,7 +587,17 @@ struct LaneView: View, Equatable {
|
|||||||
// The dragged items' own sizes, frozen at drag start — the one thing that is
|
// The dragged items' own sizes, frozen at drag start — the one thing that is
|
||||||
// (03-board-ui.md § Motion).
|
// (03-board-ui.md § Motion).
|
||||||
units: members.map { LaneLayoutMath.displayUnits(of: $0) },
|
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()
|
return payload.itemProvider()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1047,3 +1047,116 @@ struct HapticTickTests {
|
|||||||
#expect(ticks() == afterFirstLanding + 1, "slot A to slot B is one new landing spot")
|
#expect(ticks() == afterFirstLanding + 1, "slot A to slot B is one new landing spot")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - The seeded pickup
|
||||||
|
|
||||||
|
/// **The shadow replaces the item's original space at drag start** (DRAG-REORDER.md § The pieces):
|
||||||
|
/// `begin` takes the pickup's own slot as a seed, so the lift-out and the shadow's arrival are one
|
||||||
|
/// transaction with identical geometry instead of a gap that closes un-animated and springs back
|
||||||
|
/// open at the first `dropUpdated`.
|
||||||
|
///
|
||||||
|
/// The seed bypasses `propose` deliberately — a pickup is not a *new landing spot*, so the
|
||||||
|
/// alignment tick stays reserved for genuine retargets, and the first real sample's re-propose of
|
||||||
|
/// the same slot is `propose`'s ordinary silent early-out.
|
||||||
|
@MainActor
|
||||||
|
@Suite("The seeded pickup")
|
||||||
|
struct SeededPickupTests {
|
||||||
|
|
||||||
|
private static let lane1 = ItemID(rawValue: Ident.lane1)
|
||||||
|
private static let card1 = ItemID(rawValue: Ident.card1)
|
||||||
|
|
||||||
|
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"))
|
||||||
|
return fixture
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cardFolder(_ store: BoardStore) -> URL {
|
||||||
|
store.rootURL
|
||||||
|
.appendingPathComponent(Ident.lane1, isDirectory: true)
|
||||||
|
.appendingPathComponent(Ident.card1, isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A seeded pickup proposes the own slot from the first frame")
|
||||||
|
func seedIsTheProposal() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let session = DragSession()
|
||||||
|
let seed = DropTarget(boardRoot: store.rootKey, container: .lane(Self.lane1), index: 0)
|
||||||
|
|
||||||
|
session.beginCards(
|
||||||
|
[Self.card1], folders: [cardFolder(store)], heights: [44],
|
||||||
|
container: .board, source: store, seed: seed
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(session.proposal == seed, "the shadow holds the vacated space before any sample")
|
||||||
|
#expect(
|
||||||
|
session.laneProposal(onBoardRooted: store.rootKey, laneID: Self.lane1) == 0,
|
||||||
|
"the lane reads the seed through the same accessor every retargeted proposal uses"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The seed is silent, and the first sample's re-propose stays silent")
|
||||||
|
func seedDoesNotTick() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let session = DragSession()
|
||||||
|
var count = 0
|
||||||
|
session.hapticTick = { count += 1 }
|
||||||
|
let seed = DropTarget(boardRoot: store.rootKey, container: .lane(Self.lane1), index: 0)
|
||||||
|
|
||||||
|
session.beginCards(
|
||||||
|
[Self.card1], folders: [cardFolder(store)], heights: [44],
|
||||||
|
container: .board, source: store, seed: seed
|
||||||
|
)
|
||||||
|
#expect(count == 0, "a pickup is not a new landing spot")
|
||||||
|
|
||||||
|
session.propose(seed)
|
||||||
|
#expect(count == 0, "the first dropUpdated re-proposes the seeded slot — the early-out holds")
|
||||||
|
|
||||||
|
session.propose(DropTarget(boardRoot: store.rootKey, container: .lane(Self.lane1), index: 1))
|
||||||
|
#expect(count == 1, "a genuine retarget still ticks exactly as before")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An unseeded pickup still begins with no proposal")
|
||||||
|
func noSeedIsTheOldContract() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let session = DragSession()
|
||||||
|
let seed = DropTarget(boardRoot: store.rootKey, container: .lane(Self.lane1), index: 0)
|
||||||
|
|
||||||
|
session.beginCards(
|
||||||
|
[Self.card1], folders: [cardFolder(store)], heights: [44],
|
||||||
|
container: .board, source: store, seed: seed
|
||||||
|
)
|
||||||
|
// A second begin without a seed — the ⌥-pickup's shape, and every trash pickup's — must not
|
||||||
|
// inherit the previous session's proposal.
|
||||||
|
session.beginCards(
|
||||||
|
[Self.card1], folders: [cardFolder(store)], heights: [44],
|
||||||
|
container: .trash, source: store
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(session.proposal == nil, "a fresh session before the first sample, exactly as documented")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A lane pickup seeds its strip slot the same way")
|
||||||
|
func laneSeedNamesTheStrip() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let session = DragSession()
|
||||||
|
let seed = DropTarget(boardRoot: store.rootKey, container: .strip, index: 0)
|
||||||
|
|
||||||
|
session.beginLanes(
|
||||||
|
[Self.lane1], folders: [store.rootURL.appendingPathComponent(Ident.lane1, isDirectory: true)],
|
||||||
|
units: [1], source: store, seed: seed
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(session.stripProposal(onBoardRooted: store.rootKey) == 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user