diff --git a/Kanban/UI/Board/BoardDrops.swift b/Kanban/UI/Board/BoardDrops.swift index d208aa5..59d83b8 100644 --- a/Kanban/UI/Board/BoardDrops.swift +++ b/Kanban/UI/Board/BoardDrops.swift @@ -502,13 +502,10 @@ struct BoardDropContext { /// bracket (`BoardStore.deleteByDrag`), so a card deleted by drop is indistinguishable on disk /// from one deleted by keystroke (04-interactions.md ▸ The trash, settled 2026-07-28). /// - /// The write is the first half; the second is the **settle** (`DragSession.commit`). The write is - /// still in flight when this returns, so the session flips from proposing to committed and the - /// slot the shadows were holding starts drawing the dropped cards themselves — "at release the - /// shadow is replaced by the dropped card(s) drawn in place immediately, the appear never waiting - /// for the echo" (03-board-ui.md § Motion, sharpened 2026-07-28). The survivors and the resolved - /// operation are handed over rather than re-derived, because the overlay must show exactly what - /// this call wrote: the same run, and a copy's originals back where a copy leaves them. + /// The write is the first half; the second is the **committed-overlay hold** + /// (`DragSession.commit`). The write is still in flight when this returns, so the session flips + /// from proposing to committed and keeps drawing the arrangement it was showing — the shadows at + /// their landing slots, the originals lifted out — until this store's echo reload lands. func commitDrop() -> Bool { guard session.isActive, let kind = session.kind, let sourceRoot = session.sourceRoot else { return false @@ -598,9 +595,8 @@ struct BoardDropContext { } } - // The committed-overlay hold: keep drawing the arrangement — the dropped cards included — - // until this store's next snapshot. - session.commit(into: store, survivors: survivors, operation: operation) + // The committed-overlay hold: keep drawing the arrangement until this store's next snapshot. + session.commit(into: store) return true } diff --git a/Kanban/UI/Board/BoardView.swift b/Kanban/UI/Board/BoardView.swift index 9f2f302..20dd756 100644 --- a/Kanban/UI/Board/BoardView.swift +++ b/Kanban/UI/Board/BoardView.swift @@ -374,11 +374,8 @@ struct BoardView: View { /// off the tidy snapped layout regardless of the live overflow. /// /// A lane being **dragged** is simply absent from the strip: it is lifted out of the resting - /// layout at pickup and stays out for as long as the drag is in flight, whatever the effective - /// operation is (DRAG-REORDER.md § Resting-layout zones), while the system drag session carries - /// its replica. At release it comes straight back — at its *landing* slot, drawn by this very - /// function, because a settled reorder renders the lanes rather than their outlines - /// (`stripSlots`, `runSlots`). + /// layout at pickup and stays out until release, whatever the effective operation is + /// (DRAG-REORDER.md § Resting-layout zones), while the system drag session carries its replica. @ViewBuilder private func laneSlot(_ lane: Lane, standard: CGFloat) -> some View { let resizing = resize.isResizing(lane.id) @@ -567,39 +564,18 @@ struct BoardView: View { } } - /// What the strip lays out: the resting lanes — the dragged run lifted out while the drag is in - /// flight — with the run opened at the proposal. + /// What the strip lays out: the resting lanes — the dragged run lifted out, whatever the + /// effective operation is — with the shadows opened at the proposal. private var stripSlots: [StripSlot] { let session = appModel.dragSession let hidden = session.hiddenMembers(onBoardRooted: store.rootURL) var slots = liveLanes.filter { !hidden.contains($0.id) }.map(StripSlot.lane) - guard let landing = session.laneLanding(onBoardRooted: store.rootURL) else { return slots } - slots.insert(contentsOf: runSlots(landing, session: session), at: min(max(0, landing.index), slots.count)) + guard let index = stripProposal else { return slots } + let shadows = session.laneUnits.enumerated().map { StripSlot.shadow(index: $0.offset, units: $0.element) } + slots.insert(contentsOf: shadows, at: min(max(0, index), slots.count)) return slots } - /// What the strip's run is made of — shadows while the drag is in flight, and **the dropped lanes - /// themselves** the instant a within-board reorder settles (03-board-ui.md § Motion: "rendering - /// the arrangement means rendering the card"). - /// - /// A within-board reorder is the easy half and the whole of what the strip owes: the lanes are - /// still in this snapshot, so the run draws the real `LaneView`s at their landing slot — keyed by - /// lane identity, so the echo reload is a content swap inside one element and the lane never - /// loses its scroll position or its masonry to the settle. - /// - /// **A cross-board lane arrival keeps its shadow**, deliberately: the destination has no lane to - /// draw yet, and a lane's face is a whole column of cards rather than a title and an icon — the - /// card level's payload-title fallback (`DroppedCardFace`) has no honest equivalent here. The - /// shadow stands until the echo, which for an arriving column is the same round trip a create - /// already takes. - private func runSlots(_ landing: DropLanding, session: DragSession) -> [StripSlot] { - if case let .dropped(drop) = landing.run, drop.isLocal { - let lanes = drop.items.compactMap { item in liveLanes.first { $0.id == item.id } } - if lanes.count == drop.items.count { return lanes.map(StripSlot.lane) } - } - return session.laneUnits.enumerated().map { StripSlot.shadow(index: $0.offset, units: $0.element) } - } - // MARK: - Grammar keys /// **Return**, narrowly (04-interactions.md ▸ Grammar): a sole selected live card begins an diff --git a/Kanban/UI/Board/DragSession.swift b/Kanban/UI/Board/DragSession.swift index 3871a2f..b5fd3bd 100644 --- a/Kanban/UI/Board/DragSession.swift +++ b/Kanban/UI/Board/DragSession.swift @@ -144,61 +144,8 @@ struct FileDropTarget: Equatable, Sendable { // MARK: - The committed-overlay hold -/// One item the hold is drawing: the identity it travelled under, and the title it wore on the way. -/// -/// The title is not a convenience. A **cross-board arrival** has no presence in the board it just -/// landed on — the write is in flight and the destination has never seen that folder — so the -/// payload's title is the whole of what its face can say until the echo brings the real card. A -/// within-board landing finds itself in the snapshot and draws its real face instead (`LaneView`). -struct DroppedItem: Equatable, Sendable { - var id: ItemID - var title: String? -} - -/// What a container draws at the drop proposal — **one value for both phases of a release**, because -/// the slot is the same slot throughout and only its content changes. -/// -/// 03-board-ui.md § Motion (sharpened 2026-07-28): "at release the shadow is replaced by the dropped -/// card(s) drawn in place immediately, the appear never waiting for the echo — a lingering shadow -/// over a hidden card is the hold failing its one job". Keeping the *index* outside the phase split -/// is what lets the reflow's animation key stay put across the release: the settle renders, it does -/// not move, so nothing about it may key motion. -struct DropLanding: Equatable, Sendable { - - /// The dropped run, as the settled overlay draws it. - struct Dropped: Equatable, Sendable { - /// The items that landed, in landing order. - var items: [DroppedItem] - - /// Whether the arriving items keep the identities they travelled under, so the overlay's - /// slots may wear the arriving cards' own keys and the echo becomes a content swap inside - /// one element — the new-card placeholder's handoff exactly (`LaneSlot`). True for a - /// **within-board move** and nothing else: a copy mints fresh GUIDs, and a cross-board - /// arrival may be reminted at the import boundary, so neither can promise a key. - var keepsIdentity: Bool - - /// Whether this board already holds these items — a rearrangement of its own, whose faces it - /// can therefore draw straight from its snapshot. False for an arrival from another board, - /// which has only `DroppedItem.title` to go on. - var isLocal: Bool - } - - enum Run: Equatable, Sendable { - /// The drag is in flight: N hit-transparent shadows hold the space open (`DragShadow`). - case shadows - /// The release has settled: the dropped items themselves, drawn at the slot the shadows were - /// holding. - case dropped(Dropped) - } - - /// Where the run opens, in the container's own order. - var index: Int - var run: Run -} - -/// **The committed-overlay hold** (DRAG-REORDER.md § The committed-overlay hold; 03-board-ui.md § -/// Motion) — the drop proposal, and the run that landed under it, kept as overlay state past the -/// release. A value, so the state machine is testable without a filesystem. +/// The hand-off condition for **the committed-overlay hold** (DRAG-REORDER.md § The +/// committed-overlay hold), as a value so the state machine is testable without a filesystem. /// /// At release the write goes to disk and the *snapshot does not change* — the one-way flow means the /// board only shows the new order once the watcher's reload lands (02-architecture.md). Dropping the @@ -208,17 +155,6 @@ struct DropLanding: Equatable, Sendable { /// app-mediated echo is normally next, and a foreign one that lands first re-grounds everything /// anyway. /// -/// ### Rendering the arrangement means rendering the card -/// -/// The hold carries *what* landed and not only *where*, because the arrangement is not an outline: -/// "at release the shadow is replaced by the dropped card(s) drawn in place immediately" — the -/// system drag image's fade then dissolves over a card that is already there, which is the whole -/// promise of the settle. `landing` is that run, in the order it lands. -/// -/// `operation` is the other half of what the overlay draws, and it settles two questions at once -/// because a move and a copy differ in exactly those two ways — see `removesOriginals` and -/// `keepsIdentity`. -/// /// The `timeout` is the same guarantee the drag session's watchdog gives the drag itself: a write /// that was refused outright (a read-only board) produces no reload at all, and an overlay with no /// hand-off coming must still dissolve and let the snapshot be the authority again. @@ -230,23 +166,6 @@ struct CommittedHold: Equatable, Sendable { /// That board's `snapshotGeneration` at the moment of the commit. var generation: Int - /// The run that landed, in landing order — the dropped cards the overlay draws in place. - /// Defaulted so the hand-off condition above can still be stated on its own. - var landing: [DroppedItem] = [] - - /// The effective operation the release committed, re-resolved at the drop. - var operation: TransferOperation = .move - - /// Whether the source board keeps its originals lifted out of the resting layout. A move took - /// them away, so it does; a **copy left them exactly where they were**, so they come back the - /// instant the write is issued — the arrangement the hold renders has the originals *and* the - /// arrivals in it, which is what the echo will show. - var removesOriginals: Bool { operation == .move } - - /// Whether the arriving items keep the identities they travelled under (`Dropped.keepsIdentity` - /// is this, narrowed to a within-board landing). - var keepsIdentity: Bool { operation == .move } - /// How long the hold may stand with no snapshot arriving. Comfortably longer than a write plus /// a watcher round trip, short enough that a refused write does not leave the board drawing an /// arrangement it never got. @@ -366,11 +285,6 @@ final class DragSession { /// The dragged items' folders, aligned 1:1 with `members` — what the cross-board commits take. @ObservationIgnored private(set) var folders: [URL] = [] - /// The dragged items' titles, aligned 1:1 with `members` — captured at pickup off the very - /// payload the pasteboard carries, and read again at the drop so the committed hold can draw a - /// **cross-board arrival**'s face before that board has ever heard of it (`DroppedItem`). - @ObservationIgnored private(set) var titles: [String?] = [] - /// The board the drag started in. Root and store are kept separately because the store may go /// away with its window mid-drag while the root — the left-hand side of the locality /// comparison — stays perfectly usable. @@ -432,9 +346,8 @@ final class DragSession { var isDraggingCards: Bool { kind == .cards } var isDraggingLanes: Bool { kind == .lanes } - /// Whether the release has **settled**: the write is issued, the proposal is being held as - /// overlay state, and every surface that was drawing shadows is now drawing the dropped items - /// (`CommittedHold`). + /// Whether the release has **settled**: the write is issued and the arrangement the session was + /// showing is being held as overlay state until the echo reload lands (`CommittedHold`). var isSettled: Bool { hold != nil } /// N — the number of contiguous shadows the proposal draws. @@ -449,27 +362,15 @@ final class DragSession { /// carries items that render in the quasi-lane, not in any lane's masonry, so no lane loses a /// card to it. /// - /// **The dragged run is lifted out whatever the effective operation is** *while the drag is in - /// flight* (DRAG-REORDER.md § Resting-layout zones): ⌥ can be pressed and released mid-drag, and - /// a layout that re-admitted the originals on every modifier flip would flap the whole board - /// under the cursor. - /// - /// **At release the operation stops being a guess**, and a settled copy's originals come back at - /// once (`CommittedHold.removesOriginals`): the copy left them exactly where they were, so the - /// arrangement the hold is drawing has both them and the arrivals in it, and hiding them a round - /// trip longer would be the same lie the lingering shadow was. A settled *move* keeps hiding - /// them, because the write really did take them away — the overlay draws them at their landing - /// slot instead, which is the whole of "rendering the arrangement means rendering the card" - /// (03-board-ui.md § Motion). - /// - /// **A drop on the trash is a move by this rule and needs no clause of its own**: the tombstone - /// really did take the cards off the live side, and the landing slot the overlay draws them at is - /// the trash's topmost row (`trashLanding`). + /// **The dragged run is lifted out whatever the effective operation is** (DRAG-REORDER.md § + /// Resting-layout zones): ⌥ can be pressed and released mid-drag, and a layout that re-admitted + /// the originals on every modifier flip would flap the whole board under the cursor. The copy's + /// originals reappear when the write lands — the hold keeps them lifted for that round trip, so + /// the arrangement on screen is the one the release proposed and stays still until the echo. func hiddenMembers(onBoardRooted root: URL) -> Set { guard isActive, side == .live, let sourceRoot, DragLocality.isSameBoard(root, sourceRoot) else { return [] } - if let hold, !hold.removesOriginals { return [] } return memberSet } @@ -504,54 +405,6 @@ final class DragSession { return proposal.index } - /// **What `laneID`'s masonry draws at the proposal** — the shadow run while the drag is in - /// flight, the dropped cards themselves once the release has settled (`DropLanding`), and `nil` - /// when no proposal names this lane. - /// - /// The index is the same index in both phases, deliberately: the settle changes what the slot - /// *contains*, never where it is, so a view can key its reflow on the index and be sure the - /// release itself animates nothing (03-board-ui.md § Motion — the un-hide is rendering). - func cardLanding(onBoardRooted root: URL, laneID: ItemID) -> DropLanding? { - guard let index = laneProposal(onBoardRooted: root, laneID: laneID) else { return nil } - return DropLanding(index: index, run: landingRun) - } - - /// The lane strip's twin of `cardLanding` — the shadow run, or the dropped lanes drawn at the - /// slot they landed in. - func laneLanding(onBoardRooted root: URL) -> DropLanding? { - guard let index = stripProposal(onBoardRooted: root) else { return nil } - return DropLanding(index: index, run: landingRun) - } - - /// The **trash column's** twin: the shadow rows the delete gesture opens at the top, and — once - /// the release has settled — the tombstoned rows themselves, drawn there from the instant of - /// release until the echo reload brings the real ones (`TrashLaneView`). - /// - /// The settle is not decoration here, it is the whole of the gesture being legible: at release - /// the dragged cards are already lifted out of their lanes (`hiddenMembers` — a delete removes - /// its originals exactly as a move does), so with nothing drawn in the trash they would simply - /// wink out of existence for a round trip. - func trashLanding(onBoardRooted root: URL) -> DropLanding? { - guard let index = trashProposal(onBoardRooted: root) else { return nil } - return DropLanding(index: index, run: landingRun) - } - - /// The phase, as the two accessors above read it. - /// - /// `isLocal` is what keeps a **colliding cross-board arrival** from being drawn twice: only a - /// board that already holds the dragged items may resolve their faces (and their identities) - /// against its own snapshot, and an arrival's payload title is the honest answer everywhere - /// else. - private var landingRun: DropLanding.Run { - guard let hold else { return .shadows } - let isLocal = sourceRoot.map { DragLocality.isSameBoard($0, hold.boardRoot) } ?? false - return .dropped(DropLanding.Dropped( - items: hold.landing, - keepsIdentity: hold.keepsIdentity && isLocal, - isLocal: isLocal - )) - } - /// The members that are still there — **rule 3 of the re-grounding trio**: drag membership is a /// UUID set that vanished items leave silently (`TransientBoardState.dragMembers`), and when the /// last one goes the drag has emptied itself. Partial vanishing drops the survivors, matching @@ -629,19 +482,18 @@ final class DragSession { func beginCards( _ members: [ItemID], folders: [URL], - titles: [String?], heights: [CGFloat], side: Liveness, source: BoardStore ) { - begin(kind: .cards, members: members, folders: folders, titles: titles, side: side, source: source) + begin(kind: .cards, members: members, folders: folders, side: side, source: source) cardHeights = heights laneUnits = [] } /// Begins a lane session. - func beginLanes(_ members: [ItemID], folders: [URL], titles: [String?], units: [Int], source: BoardStore) { - begin(kind: .lanes, members: members, folders: folders, titles: titles, side: .live, source: source) + func beginLanes(_ members: [ItemID], folders: [URL], units: [Int], source: BoardStore) { + begin(kind: .lanes, members: members, folders: folders, side: .live, source: source) laneUnits = units cardHeights = [] } @@ -650,7 +502,6 @@ final class DragSession { kind: DragKind, members: [ItemID], folders: [URL], - titles: [String?], side: Liveness, source: BoardStore ) { @@ -659,7 +510,6 @@ final class DragSession { self.members = members self.memberSet = Set(members) self.folders = folders - self.titles = titles self.side = side self.sourceStore = source self.sourceRoot = source.rootURL @@ -703,7 +553,6 @@ final class DragSession { members = [] memberSet = [] folders = [] - titles = [] cardHeights = [] laneUnits = [] proposal = nil @@ -718,32 +567,16 @@ final class DragSession { /// Enters the committed phase: the arrangement the session was showing stays on screen until /// `store` applies its next snapshot (`CommittedHold`). /// - /// Everything that drives the rendering — the proposal, the members, the source root — is kept + /// Everything that drives the rendering — the members, the proposal, the source root — is kept /// exactly as it was, so "keeps rendering the arrangement it was showing" needs no second - /// mechanism. What *changes* at this instant is what the proposal's slot draws: the shadows are - /// over, and `survivors` — the run this drop actually wrote, vanished members already dropped — - /// becomes the hold's `landing`, drawn there as ordinary card faces (`DropLanding`). A copy's - /// originals come back in the same render pass (`hiddenMembers`); a move's stay lifted, because - /// the overlay is now drawing them at their landing slot. - /// - /// - Parameters: - /// - survivors: indices into `members` — what `BoardDropContext.commitDrop` is writing, which - /// is exactly what the overlay must show. - /// - operation: the effective operation, re-resolved at the drop. It decides both halves of - /// the overlay's grammar (`CommittedHold.removesOriginals`, `.keepsIdentity`). - func commit(into store: BoardStore, survivors: [Int], operation: TransferOperation) { + /// mechanism: the shadows stay at the landing slots and the originals stay lifted out until the + /// snapshot carrying the write arrives and the real faces take their place. + func commit(into store: BoardStore) { guard isActive else { return } sourceStore?.transient.dragMembers = .empty watchdog?.cancel() watchdog = nil - let hold = CommittedHold( - boardRoot: store.rootURL, - generation: store.snapshotGeneration, - landing: survivors.map { - DroppedItem(id: members[$0], title: titles.indices.contains($0) ? titles[$0] : nil) - }, - operation: operation - ) + let hold = CommittedHold(boardRoot: store.rootURL, generation: store.snapshotGeneration) self.hold = hold let timeout = holdTimeout holdTimeoutTask?.cancel() diff --git a/Kanban/UI/Board/DragShadow.swift b/Kanban/UI/Board/DragShadow.swift index 398db56..17f097f 100644 --- a/Kanban/UI/Board/DragShadow.swift +++ b/Kanban/UI/Board/DragShadow.swift @@ -12,12 +12,6 @@ import SwiftUI /// **Hit-transparent, always.** The strip's own drop target has to stay live beneath the shadows /// (DRAG-REORDER.md § Single-target dispatch: "shadow placeholders are hit-transparent, so the strip /// target stays live beneath them"), and a shadow that swallowed the release would strand the drop. -/// -/// **A drag's shadow lives exactly as long as the drag does.** The moment the mouse comes up the -/// slot it was holding draws the dropped card itself (`LaneView`'s `runSlots`, `DroppedCardFace`) — -/// "a lingering shadow over a hidden card is the hold failing its one job" (03-board-ui.md § Motion, -/// sharpened 2026-07-28). The one shadow that outlives a release is the cross-board *lane* arrival's, -/// which has no column to draw until the echo lands (`BoardView.runSlots`). struct DragShadow: View { /// Matched to the surface it stands in for — a lane's plate is 10, a card's is 8. diff --git a/Kanban/UI/Board/LaneView.swift b/Kanban/UI/Board/LaneView.swift index 15ba9f1..96d438b 100644 --- a/Kanban/UI/Board/LaneView.swift +++ b/Kanban/UI/Board/LaneView.swift @@ -377,9 +377,6 @@ struct LaneView: View { drops.session.beginLanes( members.map(\.id), folders: payload.folders, - // What a cross-board arrival's overlay has to draw with (`DroppedItem`) — the payload's - // own titles, so the session and the pasteboard cannot disagree about what travelled. - titles: payload.items.map(\.title), // 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) }, @@ -487,10 +484,6 @@ struct LaneView: View { // height — the run's real footprint, so the drop lands exactly here. DragShadow(cornerRadius: 8) .frame(height: height) - case let .dropped(face): - // The same run, one instant later: the release has settled and the - // dropped card is drawn where its shadow was (`DroppedCardFace`). - DroppedCardFace(card: face.card, title: face.title) } } // "Appear/disappear is scale + fade (cards scale from ~0.8 …)" @@ -575,16 +568,13 @@ struct LaneView: View { } } - /// Where a card drag lands **in this lane** and what that slot draws — a run of shadows while the - /// drag is in flight, the dropped cards themselves once the release has settled - /// (`DragSession.cardLanding`). `nil` when the proposal is elsewhere. - private var cardLanding: DropLanding? { - drops.session.cardLanding(onBoardRooted: store.rootURL, laneID: lane.id) + /// Where a card drag would land **in this lane**, or `nil` when the proposal is elsewhere. + private var cardProposal: Int? { + drops.session.laneProposal(onBoardRooted: store.rootURL, laneID: lane.id) } - /// The run's **geometry** — where it opens and what each of its slots is worth in height — or - /// `nil` when no proposal names this lane. The masonry's one make-room mechanism, and the - /// reflow's narrow animation key. + /// The shadow run this lane opens, or `nil` when no proposal names it — the masonry's one + /// make-room mechanism, and the reflow's narrow animation key. /// /// Two sessions feed it and they are mutually exclusive by construction (a file session never /// arms `DragSession`, so `isActive` is false for exactly as long as one is in flight): @@ -593,14 +583,9 @@ struct LaneView: View { /// drop lands exactly where the shadows are; /// - **a Finder file drag**, at the nominal height, one shadow per file — the cards being /// proposed do not exist yet, so there is no measured height to be faithful to. - /// - /// **Computed identically on both sides of a release**, deliberately: the settle changes what - /// the run's slots *contain*, never where they are or how much room they take, so this value — - /// the animation key — does not move at the drop. That is what makes the un-hide instant - /// rendering rather than motion (03-board-ui.md § Motion), with no suppression flag anywhere. private var shadowRun: ShadowRun? { - if let cardLanding { - return ShadowRun(position: cardLanding.index, heights: drops.session.cardHeights) + if let position = cardProposal { + return ShadowRun(position: position, heights: drops.session.cardHeights) } if let proposal = drops.session.fileLaneProposal(onBoardRooted: store.rootURL, laneID: lane.id) { return ShadowRun( @@ -625,15 +610,11 @@ struct LaneView: View { /// arriving card's identity so the handoff is one arrival rather than two (`LaneSlot`). The /// position math above is untouched by that — the key changes, the index does not — so the /// masonry cannot flinch at the moment of commit. - /// - /// The drag's run is the same story told at the other end: its slots change from shadows to the - /// dropped cards at release, at the same position and the same count, so nothing in this function - /// moves when a drop settles (`runSlots`). private var slots: [LaneSlot] { var result = renderedCards.map(LaneSlot.card) let run = shadowRun - let runPosition = run.map { min(max(0, $0.position), result.count) } + let shadowPosition = run.map { min(max(0, $0.position), result.count) } var placeholder: (position: Int, phase: NewCardPlaceholder.Phase)? if let pending = store.transient.newCardPlaceholder, pending.laneID == lane.id { let position = BoardStore.insertionIndex(after: pending.anchorCardID, among: renderedCards) @@ -641,72 +622,30 @@ struct LaneView: View { placeholder = (position, pending.phase) } - let inserted = runSlots(run) - if let runPosition { - result.insert(contentsOf: inserted, at: runPosition) + let heights = run?.heights ?? [] + if let shadowPosition { + let shadows = heights.enumerated().map { LaneSlot.shadow(index: $0.offset, height: $0.element) } + result.insert(contentsOf: shadows, at: shadowPosition) } if var placeholder { - if let runPosition, placeholder.position >= runPosition { - placeholder.position += inserted.count + if let shadowPosition, placeholder.position >= shadowPosition { + placeholder.position += heights.count } result.insert(.placeholder(placeholder.phase), at: min(placeholder.position, result.count)) } return result } - /// What the run at the proposal is made of — **the settle, as one branch**. - /// - /// While the drag is in flight it is N dashed outlines at the dragged cards' frozen heights. The - /// instant the release commits it is the cards themselves: "at release the shadow is replaced by - /// the dropped card(s) drawn in place immediately, the appear never waiting for the echo — a - /// lingering shadow over a hidden card is the hold failing its one job" (03-board-ui.md § Motion, - /// sharpened 2026-07-28). - /// - /// Where each face's content comes from is `DropLanding.Dropped.isLocal`'s answer: a within-board - /// landing is a card this snapshot still has — at its pre-drop position, or in the trash for a - /// restore — so its **real** face travels to the landing slot, and a cross-board arrival has only - /// the title it travelled under until the echo brings the rest (`DroppedCardFace`). - private func runSlots(_ run: ShadowRun?) -> [LaneSlot] { - guard let run else { return [] } - guard case let .dropped(drop) = cardLanding?.run else { - return run.heights.enumerated().map { LaneSlot.shadow(index: $0.offset, height: $0.element) } - } - return drop.items.enumerated().map { index, item in - LaneSlot.dropped(DroppedFace( - index: index, - id: item.id, - card: drop.isLocal ? snapshotCard(item.id) : nil, - title: item.title, - keepsIdentity: drop.keepsIdentity - )) - } - } - - /// The dropped item as this board already knows it, tombstones included — a restore's card is in - /// the snapshot exactly as a moved one is, only on the other side of the live/trash boundary. - /// `nil` for an arrival this board has never held. - private func snapshotCard(_ id: ItemID) -> Card? { - for lane in store.snapshot.lanes { - if let card = lane.cards.first(where: { $0.id == id }) { return card } - } - return nil - } - /// **Tombstoned cards render nowhere**, and neither do the cards of a tombstoned lane — the /// ancestor walk is absolute (01-storage-format.md § Deletion, 02-architecture.md's effective /// liveness). The lane half of that rule is `BoardView`'s, which never builds a `LaneView` for a /// tombstoned lane at all. /// - /// **A dragged card renders nowhere either, for as long as the drag is in flight.** It is lifted - /// out of the resting layout at pickup and stays out until release *whatever the effective - /// operation is* — a ⌥-copy's originals really do stay, but ⌥ can be pressed and released - /// mid-drag, and a layout that re-admitted them on every flip would flap the board under the - /// cursor (DRAG-REORDER.md § Resting-layout zones). - /// - /// **At release the lift ends** (`DragSession.hiddenMembers`): a settled copy's originals are - /// back in this list in the same render pass the copies appear at the landing slot, and a settled - /// move's stay out because the overlay is now drawing them *there* rather than here (`runSlots`). - /// Either way nothing on this board is hidden behind a shadow once the mouse is up. + /// **A dragged card renders nowhere either, for as long as the session lasts.** It is lifted out + /// of the resting layout at pickup and stays out until release *whatever the effective operation + /// is* — a ⌥-copy's originals really do stay, but ⌥ can be pressed and released mid-drag, and a + /// layout that re-admitted them on every flip would flap the board under the cursor + /// (DRAG-REORDER.md § Resting-layout zones). The copy's originals reappear when the write lands. /// /// **A card the live search filter hides renders nowhere either** (04-interactions.md § Search): /// "cards whose title *and* body both miss the query animate out". This is the one collection @@ -844,9 +783,6 @@ enum LaneSlot: Identifiable { case placeholder(NewCardPlaceholder.Phase) /// One of a drag's N contiguous shadows, at the dragged card's frozen height. case shadow(index: Int, height: CGFloat) - /// One of the **dropped** cards, drawn in its landing slot from the instant of release until the - /// echo reload brings the real one (`DroppedFace`). - case dropped(DroppedFace) var id: String { switch self { @@ -855,12 +791,6 @@ enum LaneSlot: Identifiable { // Constant per position in the run, so the shadows animate as slides when the proposal moves // rather than blinking out and back in. case let .shadow(index, _): "shadow:\(index)" - // **The placeholder's handoff, again.** A move keeps the identity it travelled under, so the - // slot wears the arriving card's own key and the echo reload swaps content inside one - // element — no removal, no insertion, no transition to fire. A copy mints a fresh GUID and a - // cross-board arrival may be reminted at the import boundary, so neither can promise a key: - // theirs is positional, and the real card's arrival reads as the arrival it is. - case let .dropped(face): face.keepsIdentity ? Self.identity(of: face.id) : "landing:\(face.index)" } } @@ -881,25 +811,6 @@ enum LaneSlot: Identifiable { } } -/// One dropped card as its landing slot draws it, for the round trip between the release and the -/// echo (03-board-ui.md § Motion ▸ the drop settle). -/// -/// `card` is the item as **this** board already holds it — a within-board landing, whose real face -/// simply moves to the landing slot. A cross-board arrival has none, and `title` is what it -/// travelled under (`DroppedItem`): enough for a face, and everything the destination can honestly -/// say before the write round-trips. -struct DroppedFace { - /// Position within the run — the positional key's whole content, for a landing that cannot - /// promise an identity. - var index: Int - var id: ItemID - var card: Card? - var title: String? - /// Whether the arriving card will wear `id`, and therefore whether this slot may key by it — - /// see `LaneSlot.id`. - var keepsIdentity: Bool -} - // MARK: - The card plate's metrics /// The card plate's geometry, spelled once because **two views draw it**: the real face @@ -1097,9 +1008,6 @@ private struct CardFaceView: View { drops.session.beginCards( ordered, folders: payload.folders, - // What a cross-board arrival's overlay has to draw with (`DroppedItem`) — the payload's - // own titles, so the session and the pasteboard cannot disagree about what travelled. - titles: payload.items.map(\.title), // The dragged items' sizes, frozen at drag start — the pickup transition scales the // replica, and its lingering "last measured frame" would mis-size the shadow and the // span-cap (03-board-ui.md § Motion). @@ -1442,92 +1350,6 @@ private struct NewCardStubView: View { } } -// MARK: - The dropped card - -/// **A dropped card, drawn at the instant of release** — 03-board-ui.md § Motion, sharpened -/// 2026-07-28: "at release the shadow is replaced by the dropped card(s) drawn in place immediately, -/// the appear never waiting for the echo (a lingering shadow over a hidden card is the hold failing -/// its one job)". The system drag image's fade then dissolves over a card that is already there, -/// which is the whole promise the settle makes. -/// -/// **`NewCardStubView.arrivingFace`'s precedent, applied to the drag**, and for the same reason: a -/// static rendition at the same numbers (`CardFaceMetrics`) is what makes the echo's swap invisible -/// rather than merely un-animated. It carries no gestures, no drop target, and no geometry -/// registration — it stands in for exactly one round trip, and every surface that reads a card's -/// drawn frame (the drop zones, the rubber band) is reading the *snapshot*'s cards, which this is -/// not one of. -/// -/// Two sources, one face (`DroppedFace`): a within-board landing draws the card the snapshot still -/// holds — icon, tint, stripe, attachments and all, so a move looks like the very card that was -/// picked up — and a cross-board arrival draws the title it travelled under under the level-default -/// symbol, because that is all the destination knows until the write lands. -private struct DroppedCardFace: View { - - let card: Card? - let title: String? - - var body: some View { - HStack(alignment: .firstTextBaseline, spacing: CardFaceMetrics.rowSpacing) { - Image(systemName: symbol) - .foregroundStyle(iconTint) - .imageScale(.medium) - Text(displayTitle ?? "Untitled") - .font(.body) - .foregroundStyle(displayTitle == nil ? .secondary : .primary) - .lineLimit(4) - .frame(maxWidth: .infinity, alignment: .leading) - attachmentsIndicator - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(CardFaceMetrics.contentPadding) - .padding(.leading, CardFaceMetrics.stripeWidth) - .background(RoundedRectangle(cornerRadius: CardFaceMetrics.cornerRadius).fill(.background.secondary)) - .overlay(alignment: .leading) { accentStripe } - // Inert, deliberately: the write naming this slot is already in flight, and a face that - // answered clicks would be offering to act on an item whose identity is a round trip away. - .allowsHitTesting(false) - .accessibilityHidden(true) - } - - /// The card's own title, or the one it travelled under — `nil` means untitled either way, and - /// "Untitled" is a rendering rather than a value (03-board-ui.md § Card face). - private var displayTitle: String? { card?.title.value ?? title } - - private var symbol: String { - guard let card else { return ItemSymbol.card } - return ItemSymbol.name(card.icon, fallback: ItemSymbol.card) - } - - private var iconTint: AnyShapeStyle { - if let card, let color = Palette.color(for: card.iconColor) { - AnyShapeStyle(color) - } else { - AnyShapeStyle(.secondary) - } - } - - @ViewBuilder - private var accentStripe: some View { - if let card, let color = Palette.color(for: card.background) { - UnevenRoundedRectangle( - topLeadingRadius: CardFaceMetrics.cornerRadius, - bottomLeadingRadius: CardFaceMetrics.cornerRadius - ) - .fill(color) - .frame(width: CardFaceMetrics.stripeWidth) - } - } - - @ViewBuilder - private var attachmentsIndicator: some View { - if let card, !card.attachments.isEmpty { - Image(systemName: "paperclip") - .font(.caption) - .foregroundStyle(.secondary) - } - } -} - // MARK: - The inline title field /// The one text field all three inline editors wear — the new-card placeholder, a card rename, and diff --git a/Kanban/UI/Board/TrashLaneView.swift b/Kanban/UI/Board/TrashLaneView.swift index be90ed5..af0e4c0 100644 --- a/Kanban/UI/Board/TrashLaneView.swift +++ b/Kanban/UI/Board/TrashLaneView.swift @@ -127,63 +127,26 @@ struct TrashLaneView: View { // MARK: - The delete gesture's landing - /// What the column draws at the drop proposal — the shadow run while the drag is in flight, the - /// tombstoned rows themselves once the release has settled (`DragSession.trashLanding`). `nil` + /// Where the delete gesture's shadow run opens in this column — always the topmost row — or `nil` /// when no proposal names the trash, which is every other moment of the app's life. - private var landing: DropLanding? { - drops.session.trashLanding(onBoardRooted: store.rootURL) + private var proposal: Int? { + drops.session.trashProposal(onBoardRooted: store.rootURL) } - /// The rows the `VStack` lays out: the entries, with the delete gesture's run opened at the top. + /// The rows the `VStack` lays out: the entries, with the delete gesture's shadow run opened at + /// the top. /// - /// **The settled run displaces the entries it is standing in for.** For the one render pass where - /// the echo has landed but the hold has not yet been retired (`BoardView` hands off on the *next* - /// snapshot, an `onChange` later), the arriving card is in both collections at once — so the real - /// entry steps aside and the overlay's row keeps the slot. That is `LaneView`'s hidden-members - /// rule at the other end of the same gesture: never draw the arrangement twice. - /// - /// The two then swap **inside one element**, because both are keyed by the card's own identity: a - /// tombstone is not a remint — the card keeps its GUID, its folder and its bytes — so this - /// landing, alone among the board's, can always promise the key. No insert, no remove, no - /// transition to fire; "the handoff must read as one arrival" (02-architecture.md ▸ - /// TransientBoardState ▸ overlays). + /// The run stands until the echo reload brings the real tombstones — the committed-overlay hold + /// keeps the arrangement the release proposed on screen for that round trip, exactly as every + /// other container's does (`CommittedHold`). private var slots: [TrashSlot] { - guard let landing else { return entries.map(TrashSlot.entry) } - - let arriving: [ItemID] - let run: [TrashSlot] - switch landing.run { - case .shadows: - arriving = [] - run = (0.. some View { - let card = store.snapshot.lanes.lazy - .flatMap(\.cards) - .first { $0.id == item.id } - return TrashRowPlate( - symbol: card.map { ItemSymbol.name($0.icon, fallback: ItemSymbol.card) } ?? ItemSymbol.card, - title: card?.title.value ?? item.title, - subtitle: nil, - isSelected: false - ) - } - // MARK: - Header /// Dimmed and hatched, with the trash symbol, the stable "Trash" title and a count badge @@ -278,11 +241,6 @@ struct TrashLaneView: View { DragShadow(cornerRadius: 6) .frame(maxWidth: .infinity) .frame(height: nominalRowHeight) - case let .dropped(item): - // The same row one instant later: the release has settled and the - // tombstone is drawn where its shadow was, rather than the cards winking - // out for a round trip (`DragSession.trashLanding`). - droppedRow(item) } } // A row is a tombstoned item, so it arrives and leaves in the card's dialect — @@ -297,7 +255,7 @@ struct TrashLaneView: View { // The reflow that opens the topmost row for the shadow, keyed on **the drop proposal and // nothing else** (03-board-ui.md § Motion's narrow keys) — the trash column's own copy of // the rule `BoardView` applies to the strip and `LaneView` to its masonry. - .animation(Motion.dragReflow(reduced: reduceMotion), value: landing?.index) + .animation(Motion.dragReflow(reduced: reduceMotion), value: proposal) // `maxHeight: .infinity` here, not just `maxWidth`, is what makes the gesture surface // below reach the column's full height rather than stopping where the last row ends — // the same fix `LaneView.scrollableCards` applies to its masonry, and for the identical @@ -323,13 +281,10 @@ struct TrashLaneView: View { /// The trash row's *appearance*, with none of anybody's behaviour — the compact, dimmed plate a /// tombstone wears. /// -/// **Three views draw it and none of them may drift**: the row itself, its own drag replica (a drag +/// **Two views draw it and neither may drift**: the row itself, and its own drag replica (a drag /// image is a snapshot, and one built out of the live plate would re-register the row's frame from /// inside the preview's geometry and then deregister it when the image went away — quietly stealing -/// the row from the rubber band and the arrow keys), and the **settled delete's** stand-in row, which -/// draws a card that has been tombstoned on disk but is not in the snapshot yet -/// (`TrashLaneView.droppedRow`). The last one is why this is a view rather than a computed property: -/// it renders for an item that has no `TrashEntry` at all. +/// the row from the rubber band and the arrow keys). private struct TrashRowPlate: View { let symbol: String @@ -387,21 +342,12 @@ private enum TrashSlot: Identifiable { /// One of the drag's N shadows, holding the topmost rows open (04-interactions.md ▸ The trash). case shadow(index: Int) - /// One of the **dropped** cards, drawn as the row it is about to be, from the instant of release - /// until the echo reload brings the real entry. - case dropped(DroppedItem) - var id: String { switch self { case let .entry(entry): Self.identity(of: entry.id) // Constant per position in the run, so a run that grows or shrinks animates as slots rather // than blinking (`LaneSlot`'s rule). case let .shadow(index): "shadow:\(index)" - // **The one landing on this board that can always promise a key.** A tombstone keeps the - // card's GUID — a delete remints nothing — so the settled row wears the arriving entry's own - // identity and the echo swaps content inside one element, where the masonry's `.dropped` has - // to key positionally whenever a copy or an import boundary might remint. - case let .dropped(item): Self.identity(of: item.id) } } @@ -580,9 +526,6 @@ private struct TrashEntryRow: View { drops.session.beginCards( rows.map(\.id), folders: payload.folders, - // What a cross-board arrival's overlay draws with (`DroppedItem`) — the payload's own - // titles, which for a trash row are the tombstone's. - titles: payload.items.map(\.title), heights: rows.map { _ in LaneDropRegistry.nominalCardHeight }, side: .trashed, source: store diff --git a/KanbanTests/DragSessionTests.swift b/KanbanTests/DragSessionTests.swift index fb7384d..92329e2 100644 --- a/KanbanTests/DragSessionTests.swift +++ b/KanbanTests/DragSessionTests.swift @@ -276,35 +276,22 @@ struct CommittedHoldTests { func theTimeoutFigure() { #expect(CommittedHold.timeout == .milliseconds(1500)) } - - /// The two questions the effective operation settles at once, which is why the hold carries it - /// rather than a pair of flags (`CommittedHold`). - @Test("A move takes the originals away and keeps their identities; a copy does neither") - func theOperationDecidesBothHalves() { - var hold = Self.hold - hold.operation = .move - #expect(hold.removesOriginals) - #expect(hold.keepsIdentity) - hold.operation = .copy - #expect(!hold.removesOriginals) - #expect(!hold.keepsIdentity) - } } -// MARK: - The settle +// MARK: - The committed-overlay hold, in the session -/// **The drop settle** (03-board-ui.md § Motion, sharpened 2026-07-28): what the session renders -/// between the release and the echo. The claims here are the pure state the board's surfaces read — -/// what the proposal's slot draws (`DragSession.cardLanding`) and which originals stay lifted out -/// (`hiddenMembers`) — so the whole ruling is checkable without a view: "at release the shadow is -/// replaced by the dropped card(s) drawn in place immediately … a lingering shadow over a hidden -/// card is the hold failing its one job". +/// **What the session renders between the release and the echo** (DRAG-REORDER.md § The +/// committed-overlay hold): the write is in flight and the snapshot has not moved, so the session +/// keeps drawing the arrangement it was showing — the shadows at their landing slots, the originals +/// lifted out — until the destination store applies its next snapshot, or the deadline says none is +/// coming. The claims here are the pure state the board's surfaces read (`laneProposal`, +/// `trashProposal`, `hiddenMembers`), so the whole ruling is checkable without a view. /// /// A **real store over a real temp board**, like the write suites: `commit` names the destination /// store, and the session's own re-grounding reads that store's transient state, so a stub would be /// standing in for exactly the thing under test. Nothing here writes. @MainActor -@Suite("The drop settle") +@Suite("The committed hold") struct DropSettleTests { private static let lane1 = ItemID(rawValue: Ident.lane1) @@ -324,6 +311,9 @@ struct DropSettleTests { } /// A session mid-drag: `members` picked up out of `lane1`, proposing into it at `index`. + /// + /// The titles ride along only so the fixtures read as the cards they name — nothing in the + /// session reads them. private func proposing( _ store: BoardStore, members: [(id: ItemID, title: String?)] = [(card1, "First")], @@ -349,7 +339,6 @@ struct DropSettleTests { .appendingPathComponent(Ident.lane1, isDirectory: true) .appendingPathComponent($0.id.rawValue, isDirectory: true) }, - titles: members.map(\.title), heights: members.map { _ in 44 }, side: .live, source: store @@ -369,7 +358,7 @@ struct DropSettleTests { // MARK: In flight - @Test("While the drag is in flight the slot is a run of shadows and the originals are lifted out") + @Test("While the drag is in flight the shadow run opens at the proposal and the originals are lifted out") func inFlightDrawsShadows() throws { let fixture = try makeBoard() defer { fixture.tearDown() } @@ -377,81 +366,42 @@ struct DropSettleTests { let session = proposing(store) #expect(!session.isSettled) - #expect(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1)?.run == .shadows) + #expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == 2) #expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1]) } - // MARK: The settle + // MARK: The hold - @Test("A settled move draws the dropped card at the proposal, and draws no shadow") - func settledMoveDrawsTheCard() throws { + /// "The session flips from proposing to committed and keeps rendering the arrangement it was + /// showing": the write is on its way but the snapshot has not moved, so nothing about the + /// release may change what is on screen — the shadows stay at their landing slot and the + /// originals stay lifted out until the echo reload brings the real faces. + @Test("A committed hold keeps the shadows at their landing slot and the originals lifted out") + func theHoldKeepsTheArrangement() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = proposing(store) - session.commit(into: store, survivors: [0], operation: .move) + session.commit(into: store) #expect(session.isSettled) - let landing = try #require(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1)) - // The slot has not moved — only what it contains has, which is what keeps the settle out of - // every animation key on the board. - #expect(landing.index == 2) - let drop = try #require(landing.dropped) - #expect(drop.items == [DroppedItem(id: Self.card1, title: "First")]) - // A within-board move: the arriving card wears the identity it travelled under, so the - // overlay's slot can key by it and the echo is a content swap inside one element. - #expect(drop.keepsIdentity) - #expect(drop.isLocal) - // The original stays lifted, because the write really did take it away — the overlay is - // drawing it at its landing slot instead. + #expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == 2) #expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1]) } - @Test("A settled copy puts the originals back in the same render pass that draws the copies") - func settledCopyRestoresTheOriginals() throws { - let fixture = try makeBoard() - defer { fixture.tearDown() } - let store = try BoardStore(rootURL: fixture.root) - let session = proposing(store) - - session.commit(into: store, survivors: [0], operation: .copy) - - // A copy left them exactly where they were, and the arrangement the hold renders says so. - #expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty) - let drop = try #require(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1)?.dropped) - #expect(drop.items.map(\.id) == [Self.card1]) - // Fresh GUIDs are coming, so no slot may claim one: the landing keys positionally. - #expect(!drop.keepsIdentity) - } - - @Test("The run the overlay draws is the run the commit wrote — a vanished member is not drawn") - func onlySurvivorsAreDrawn() throws { - let fixture = try makeBoard() - defer { fixture.tearDown() } - let store = try BoardStore(rootURL: fixture.root) - let session = proposing(store, members: [(Self.card1, "First"), (Self.card2, nil)]) - - // Rule 3 of the re-grounding trio: a partly emptied drag drops the survivors, and the - // overlay must show exactly those. - session.commit(into: store, survivors: [1], operation: .move) - - let drop = try #require(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1)?.dropped) - #expect(drop.items == [DroppedItem(id: Self.card2, title: nil)]) - } - @Test("A settled release is past retargeting: a late callback cannot move or withdraw it") func settledProposalsAreFinal() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = proposing(store) - session.commit(into: store, survivors: [0], operation: .move) + session.commit(into: store) session.propose(nil) session.propose(DropTarget(boardRoot: store.rootURL, container: .lane(Self.lane1), index: 0)) - #expect(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1)?.index == 2) + #expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == 2) } // MARK: The trash's landing @@ -476,44 +426,32 @@ struct DropSettleTests { let store = try BoardStore(rootURL: fixture.root) let session = proposingIntoTheTrash(store) - let landing = try #require(session.trashLanding(onBoardRooted: store.rootURL)) - #expect(landing.index == 0) - #expect(landing.run == .shadows) + #expect(session.trashProposal(onBoardRooted: store.rootURL) == 0) // The proposal names one container and one only: the lane the cards came out of draws // nothing, and neither does the strip. - #expect(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil) + #expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil) #expect(session.stripProposal(onBoardRooted: store.rootURL) == nil) - // And they are still lifted out of the lane while the drag is in flight, as ever. + // And they are lifted out of the lane, as ever. #expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1]) } - /// The settle, at the one landing whose cards would otherwise wink out of existence: the write - /// takes them off the live side, so the trash has to draw them from the instant of release. - @Test("A settled trash drop draws the tombstoned rows on top and keeps the originals lifted") - func trashSettleDrawsTheRows() throws { + /// The delete's own hold: the write takes the cards off the live side, and the shadow rows keep + /// the space they landed in until the echo brings the real tombstones. + @Test("A committed trash drop keeps its shadow rows on top and the originals lifted") + func trashHoldKeepsTheRows() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = proposingIntoTheTrash(store, members: [(Self.card1, "First"), (Self.card2, "Second")]) - // A delete is committed as the move it is — the write really did take the originals away. - session.commit(into: store, survivors: [0, 1], operation: .move) + session.commit(into: store) - let landing = try #require(session.trashLanding(onBoardRooted: store.rootURL)) - #expect(landing.index == 0, "the slot does not move at the settle — only what it contains") - let drop = try #require(landing.dropped) - #expect(drop.items == [ - DroppedItem(id: Self.card1, title: "First"), - DroppedItem(id: Self.card2, title: "Second") - ]) - // A tombstone remints nothing, so the settled rows may wear the cards' own identities and the - // echo reload swaps content inside one element rather than removing and inserting. - #expect(drop.keepsIdentity) - #expect(drop.isLocal) + #expect(session.trashProposal(onBoardRooted: store.rootURL) == 0) + #expect(session.shadowCount == 2) #expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1, Self.card2]) } - @Test("A lane session never draws a trash landing") + @Test("A lane session never proposes into the trash") func laneSessionsHaveNoTrashLanding() throws { let fixture = try makeBoard() defer { fixture.tearDown() } @@ -522,13 +460,12 @@ struct DropSettleTests { session.beginLanes( [Self.lane1], folders: [store.rootURL.appendingPathComponent(Ident.lane1, isDirectory: true)], - titles: ["Todo"], units: [1], source: store ) session.propose(DropTarget(boardRoot: store.rootURL, container: .trash, index: 0)) - #expect(session.trashLanding(onBoardRooted: store.rootURL) == nil) + #expect(session.trashProposal(onBoardRooted: store.rootURL) == nil) } // MARK: The hand-off @@ -539,13 +476,13 @@ struct DropSettleTests { defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = proposing(store) - session.commit(into: store, survivors: [0], operation: .move) + session.commit(into: store) session.handOff(root: store.rootURL, generation: store.snapshotGeneration + 1) #expect(!session.isSettled) #expect(!session.isActive) - #expect(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil) + #expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil) #expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty) } @@ -555,12 +492,12 @@ struct DropSettleTests { defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = proposing(store) - session.commit(into: store, survivors: [0], operation: .move) + session.commit(into: store) session.end() #expect(!session.isSettled) - #expect(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil) + #expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil) #expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty) #expect(store.transient.dragMembers.ids.isEmpty) } @@ -580,12 +517,12 @@ struct DropSettleTests { // wall clock in the suite for a claim about the discard rather than about the clock. session.holdTimeout = .milliseconds(20) - session.commit(into: store, survivors: [0], operation: .move) + session.commit(into: store) #expect(session.isSettled) #expect(await settles { !session.isSettled }, "the deadline must dissolve an overlay with no hand-off coming") #expect(!session.isActive) - #expect(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil) + #expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil) #expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty) #expect(store.transient.dragMembers.ids.isEmpty) } @@ -598,7 +535,7 @@ struct DropSettleTests { defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let session = proposing(store) - session.commit(into: store, survivors: [0], operation: .move) + session.commit(into: store) let hold = try #require(session.hold) session.expire(CommittedHold(boardRoot: store.rootURL, generation: 999)) @@ -616,7 +553,7 @@ struct DropSettleTests { let store = try BoardStore(rootURL: fixture.root) let session = proposing(store) session.holdTimeout = .milliseconds(20) - session.commit(into: store, survivors: [0], operation: .move) + session.commit(into: store) // The echo lands well inside the deadline, and the user starts another drag immediately — // the lifecycle trap the watchdog was written for, at the hold's end of the session. @@ -628,15 +565,3 @@ struct DropSettleTests { #expect(session.hold == nil) } } - -// MARK: - Reading a landing - -extension DropLanding { - - /// The dropped run, or `nil` while the slot is still a run of shadows — a test-side convenience - /// so a claim about the settle reads as one line rather than as a `case let` dance. - var dropped: Dropped? { - if case let .dropped(drop) = run { return drop } - return nil - } -}