diff --git a/Kanban/UI/Board/BoardDrops.swift b/Kanban/UI/Board/BoardDrops.swift index b5f5bca..4c634da 100644 --- a/Kanban/UI/Board/BoardDrops.swift +++ b/Kanban/UI/Board/BoardDrops.swift @@ -456,6 +456,14 @@ struct BoardDropContext { /// /// The commit is the **destination** store's, one `performWrite` bracket per gesture whatever the /// set's size (DRAG-REORDER.md § The drop commits). + /// + /// 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. func commitDrop() -> Bool { guard session.isActive, let kind = session.kind, let sourceRoot = session.sourceRoot else { return false @@ -514,8 +522,9 @@ struct BoardDropContext { } } - // The committed-overlay hold: keep drawing the arrangement until this store's next snapshot. - session.commit(into: store) + // 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) return true } diff --git a/Kanban/UI/Board/BoardView.swift b/Kanban/UI/Board/BoardView.swift index 4531074..a81e64a 100644 --- a/Kanban/UI/Board/BoardView.swift +++ b/Kanban/UI/Board/BoardView.swift @@ -383,8 +383,11 @@ 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 until release, whatever the effective operation is - /// (DRAG-REORDER.md § Resting-layout zones), while the system drag session carries its replica. + /// 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`). @ViewBuilder private func laneSlot(_ lane: Lane, standard: CGFloat) -> some View { let resizing = resize.isResizing(lane.id) @@ -556,18 +559,39 @@ struct BoardView: View { } } - /// 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. + /// 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. 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 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)) + 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)) 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 39d41b8..9c5a41c 100644 --- a/Kanban/UI/Board/DragSession.swift +++ b/Kanban/UI/Board/DragSession.swift @@ -57,8 +57,61 @@ struct FileDropTarget: Equatable, Sendable { // MARK: - The committed-overlay hold -/// 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. +/// 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. /// /// 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 @@ -68,7 +121,18 @@ struct FileDropTarget: Equatable, Sendable { /// app-mediated echo is normally next, and a foreign one that lands first re-grounds everything /// anyway. /// -/// The `deadline` is the same guarantee the drag session's watchdog gives the drag itself: a write +/// ### 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. struct CommittedHold: Equatable, Sendable { @@ -79,6 +143,23 @@ 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. @@ -198,6 +279,11 @@ 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. @@ -243,7 +329,13 @@ final class DragSession { @ObservationIgnored private var watchdog: Task? @ObservationIgnored private var fileWatchdog: Task? - @ObservationIgnored private var holdTimeout: Task? + @ObservationIgnored private var holdTimeoutTask: Task? + + /// How long this session's holds may stand with no snapshot arriving — `CommittedHold.timeout`, + /// and a `var` for one reason only: the discard path is a `Task` sleeping on the main actor, and + /// a test that had to wait the real figure out would be a 1.5 s wall clock in the suite + /// (`DragSessionTests`). Nothing in the app writes it. + @ObservationIgnored var holdTimeout: Duration = CommittedHold.timeout init() {} @@ -253,6 +345,11 @@ 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`). + var isSettled: Bool { hold != nil } + /// N — the number of contiguous shadows the proposal draws. var shadowCount: Int { members.count } @@ -265,14 +362,23 @@ 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** (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 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). 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 } @@ -294,6 +400,41 @@ 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 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 @@ -362,18 +503,19 @@ final class DragSession { func beginCards( _ members: [ItemID], folders: [URL], + titles: [String?], heights: [CGFloat], side: Liveness, source: BoardStore ) { - begin(kind: .cards, members: members, folders: folders, side: side, source: source) + begin(kind: .cards, members: members, folders: folders, titles: titles, side: side, source: source) cardHeights = heights laneUnits = [] } /// Begins a lane session. - func beginLanes(_ members: [ItemID], folders: [URL], units: [Int], source: BoardStore) { - begin(kind: .lanes, members: members, folders: folders, side: .live, source: source) + 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) laneUnits = units cardHeights = [] } @@ -382,6 +524,7 @@ final class DragSession { kind: DragKind, members: [ItemID], folders: [URL], + titles: [String?], side: Liveness, source: BoardStore ) { @@ -390,6 +533,7 @@ 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 @@ -402,8 +546,12 @@ final class DragSession { } /// Records a new proposal. `nil` withdraws it — rule 2's "the shadow withdraws". + /// + /// **A settled release is past retargeting**: the write naming that slot is already on its way, + /// so a late callback arriving after the commit — a stray `dropUpdated`, a revalidation — must + /// not move or withdraw the arrangement the hold is drawing. func propose(_ target: DropTarget?) { - guard isActive, proposal != target else { return } + guard isActive, hold == nil, proposal != target else { return } proposal = target } @@ -429,6 +577,7 @@ final class DragSession { members = [] memberSet = [] folders = [] + titles = [] cardHeights = [] laneUnits = [] proposal = nil @@ -443,25 +592,55 @@ 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 members, the proposal, the source root — is kept + /// Everything that drives the rendering — the proposal, the members, the source root — is kept /// exactly as it was, so "keeps rendering the arrangement it was showing" needs no second - /// 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) { + /// 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) { guard isActive else { return } sourceStore?.transient.dragMembers = .empty watchdog?.cancel() watchdog = nil - let hold = CommittedHold(boardRoot: store.rootURL, generation: store.snapshotGeneration) + 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 + ) self.hold = hold - holdTimeout?.cancel() - holdTimeout = Task { @MainActor [weak self] in - try? await Task.sleep(for: CommittedHold.timeout) - guard !Task.isCancelled, let self, self.hold == hold else { return } - withAnimation(Motion.dragReflow(reduced: Motion.prefersReducedMotion)) { self.end() } + let timeout = holdTimeout + holdTimeoutTask?.cancel() + holdTimeoutTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: timeout) + guard !Task.isCancelled, let self else { return } + self.expire(hold) } } + /// **The failed write's path**: no echo is coming, so the hold discards and the board animates + /// back to snapshot order (03-board-ui.md § Motion — "the width-drag rollback posture: the + /// action visibly doesn't happen"). The one path in the settle that *is* motion, and the reason + /// it wears `Motion.dragReflow`: what moves is the arrangement un-happening. + /// + /// The timeout's own body, spelled as a method rather than inlined in the `Task`, so the discard + /// can be pinned directly as well as through the clock (`DragSessionTests`). A hold that has + /// already been handed off — or replaced by a second drag's — is not this one's to end. + func expire(_ hold: CommittedHold) { + guard self.hold == hold else { return } + withAnimation(Motion.dragReflow(reduced: Motion.prefersReducedMotion)) { end() } + } + /// The hand-off: a snapshot landed on `root`, so an overlay standing in for it dissolves. /// /// Called from every board window's own snapshot-generation watch, which is why the hold names @@ -473,8 +652,8 @@ final class DragSession { private func endHold() { hold = nil - holdTimeout?.cancel() - holdTimeout = nil + holdTimeoutTask?.cancel() + holdTimeoutTask = nil } /// Cleanup for a session-phase event SwiftUI reports. diff --git a/Kanban/UI/Board/DragShadow.swift b/Kanban/UI/Board/DragShadow.swift index 17f097f..398db56 100644 --- a/Kanban/UI/Board/DragShadow.swift +++ b/Kanban/UI/Board/DragShadow.swift @@ -12,6 +12,12 @@ 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 6609892..122acb4 100644 --- a/Kanban/UI/Board/LaneView.swift +++ b/Kanban/UI/Board/LaneView.swift @@ -358,6 +358,9 @@ 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) }, @@ -466,6 +469,10 @@ 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 …)" @@ -550,13 +557,16 @@ struct LaneView: View { } } - /// 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) + /// 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) } - /// 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. + /// 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. /// /// 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): @@ -565,9 +575,14 @@ 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 position = cardProposal { - return ShadowRun(position: position, heights: drops.session.cardHeights) + if let cardLanding { + return ShadowRun(position: cardLanding.index, heights: drops.session.cardHeights) } if let proposal = drops.session.fileLaneProposal(onBoardRooted: store.rootURL, laneID: lane.id) { return ShadowRun( @@ -592,11 +607,15 @@ 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 shadowPosition = run.map { min(max(0, $0.position), result.count) } + let runPosition = 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) @@ -604,30 +623,72 @@ struct LaneView: View { placeholder = (position, pending.phase) } - 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) + let inserted = runSlots(run) + if let runPosition { + result.insert(contentsOf: inserted, at: runPosition) } if var placeholder { - if let shadowPosition, placeholder.position >= shadowPosition { - placeholder.position += heights.count + if let runPosition, placeholder.position >= runPosition { + placeholder.position += inserted.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 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 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 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 @@ -732,6 +793,9 @@ 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 { @@ -740,6 +804,12 @@ 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)" } } @@ -760,6 +830,25 @@ 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 @@ -984,6 +1073,9 @@ 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). @@ -1357,6 +1449,92 @@ 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, no geometry registration +/// and no carousel — 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 e715652..b102e08 100644 --- a/Kanban/UI/Board/TrashLaneView.swift +++ b/Kanban/UI/Board/TrashLaneView.swift @@ -397,6 +397,9 @@ 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 73dca71..749dad0 100644 --- a/KanbanTests/DragSessionTests.swift +++ b/KanbanTests/DragSessionTests.swift @@ -181,4 +181,298 @@ struct CommittedHoldTests { #expect(!Self.hold.isRetired(byRoot: Self.here, generation: 0)) #expect(!Self.hold.isRetired(byRoot: Self.here, generation: 6)) } + + /// The figure 03-board-ui.md fixes for a hold with no echo coming: long enough for a write plus a + /// watcher round trip, short enough that a refused write does not leave the board drawing an + /// arrangement it never got. + @Test("The deadline is the design's own figure") + 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 + +/// **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". +/// +/// 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") +struct DropSettleTests { + + private static let lane1 = ItemID(rawValue: Ident.lane1) + private static let card1 = ItemID(rawValue: Ident.card1) + private static let card2 = ItemID(rawValue: Ident.card2) + + /// Two cards in the first lane — enough for a run of two, and for one of them to vanish + /// mid-flight while the other still lands. + 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")) + try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) + try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) + return fixture + } + + /// A session mid-drag: `members` picked up out of `lane1`, proposing into it at `index`. + private func proposing( + _ store: BoardStore, + members: [(id: ItemID, title: String?)] = [(card1, "First")], + at index: Int = 2 + ) -> DragSession { + let session = DragSession() + pickUp(session, from: store, members: members) + session.propose(DropTarget(boardRoot: store.rootURL, laneID: Self.lane1, index: index)) + return session + } + + /// The pickup half, on a session that may already have had a life — the second drag in + /// `aRetiredHoldsTimeoutIsCancelled` is the whole reason it is separable. + private func pickUp( + _ session: DragSession, + from store: BoardStore, + members: [(id: ItemID, title: String?)] = [(card1, "First")] + ) { + session.beginCards( + members.map(\.id), + folders: members.map { + store.rootURL + .appendingPathComponent(Ident.lane1, isDirectory: true) + .appendingPathComponent($0.id.rawValue, isDirectory: true) + }, + titles: members.map(\.title), + heights: members.map { _ in 44 }, + side: .live, + source: store + ) + } + + /// Polls for `condition`, because the timeout's discard is a `Task` on this very actor: the test + /// has to yield for it to run at all. Bounded, so a discard that never comes fails rather than + /// hangs. + private func settles(_ condition: () -> Bool) async -> Bool { + for _ in 0..<200 { + if condition() { return true } + try? await Task.sleep(for: .milliseconds(5)) + } + return condition() + } + + // MARK: In flight + + @Test("While the drag is in flight the slot is a run of shadows and the originals are lifted out") + func inFlightDrawsShadows() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let session = proposing(store) + + #expect(!session.isSettled) + #expect(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1)?.run == .shadows) + #expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1]) + } + + // MARK: The settle + + @Test("A settled move draws the dropped card at the proposal, and draws no shadow") + func settledMoveDrawsTheCard() 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) + + #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.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.propose(nil) + session.propose(DropTarget(boardRoot: store.rootURL, laneID: Self.lane1, index: 0)) + + #expect(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1)?.index == 2) + } + + // MARK: The hand-off + + @Test("The hand-off clears the hold and the overlay with it — the snapshot is the authority again") + func handOffClearsEverything() 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.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.hiddenMembers(onBoardRooted: store.rootURL).isEmpty) + } + + @Test("Ending the session outright ends the hold with it") + func endClearsTheHold() 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.end() + + #expect(!session.isSettled) + #expect(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1) == nil) + #expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty) + #expect(store.transient.dragMembers.ids.isEmpty) + } + + // MARK: The timeout — the failed write's path + + /// "A failed write discards the proposal and the board animates back to snapshot order" + /// (03-board-ui.md § Motion). A write refused outright produces no reload at all, so the deadline + /// is the only thing standing between the board and an arrangement it never got. + @Test("A hold with no echo coming times out, and the board is left with its snapshot order") + func theTimeoutDiscardsTheHold() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let session = proposing(store) + // The seam: the real figure is `CommittedHold.timeout`, and waiting it out would be 1.5 s of + // 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) + #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.hiddenMembers(onBoardRooted: store.rootURL).isEmpty) + #expect(store.transient.dragMembers.ids.isEmpty) + } + + /// The discard's own body, called directly — the half of the timeout that is a decision rather + /// than a wait, and the guard that makes the wait harmless. + @Test("The discard ends the hold it was armed for, and no other") + func theDiscardEndsOnlyItsOwnHold() 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) + let hold = try #require(session.hold) + + session.expire(CommittedHold(boardRoot: store.rootURL, generation: 999)) + #expect(session.isSettled, "a hold this session is not holding is not this session's to end") + + session.expire(hold) + #expect(!session.isSettled) + #expect(!session.isActive) + } + + @Test("A retired hold's deadline never reaches the next drag") + func aRetiredHoldsTimeoutIsCancelled() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let session = proposing(store) + session.holdTimeout = .milliseconds(20) + session.commit(into: store, survivors: [0], operation: .move) + + // 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. + session.handOff(root: store.rootURL, generation: store.snapshotGeneration + 1) + pickUp(session, from: store, members: [(Self.card2, "Second")]) + + try? await Task.sleep(for: .milliseconds(80)) + #expect(session.isActive, "the retired hold's deadline must not end the drag that followed it") + #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 + } }