diff --git a/DRAG-REORDER.md b/DRAG-REORDER.md index c6a6f66..c7251f4 100644 --- a/DRAG-REORDER.md +++ b/DRAG-REORDER.md @@ -34,7 +34,7 @@ Lanes get this for free and the invariant is worth stating: a within-board lane 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. +- **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 diff --git a/Kanban/UI/Board/CardFaceView.swift b/Kanban/UI/Board/CardFaceView.swift index e15a35d..3eebd9f 100644 --- a/Kanban/UI/Board/CardFaceView.swift +++ b/Kanban/UI/Board/CardFaceView.swift @@ -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, + 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 diff --git a/Kanban/UI/Board/DragSession.swift b/Kanban/UI/Board/DragSession.swift index f31aff8..5f4a22e 100644 --- a/Kanban/UI/Board/DragSession.swift +++ b/Kanban/UI/Board/DragSession.swift @@ -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. diff --git a/Kanban/UI/Board/LaneView.swift b/Kanban/UI/Board/LaneView.swift index d2e4083..310efa3 100644 --- a/Kanban/UI/Board/LaneView.swift +++ b/Kanban/UI/Board/LaneView.swift @@ -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() } diff --git a/KanbanTests/DragSessionTests.swift b/KanbanTests/DragSessionTests.swift index efb51e2..dfb4233 100644 --- a/KanbanTests/DragSessionTests.swift +++ b/KanbanTests/DragSessionTests.swift @@ -1047,3 +1047,116 @@ struct HapticTickTests { #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) + } +}