Redraw a copy's originals at the source — the resting layout follows the operation
The modifier flip (⌥ copy / ⌘ move) now reflows the source board once: a copy re-admits the dragged originals into the resting layout, standing dimmed in place, and a move lifts them out as before. This retires DRAG-REORDER.md's operation-blind carve-out and makes 04-interactions.md's "originals stay" true in flight, not just at echo. The one seam is DragSession.hiddenMembers reading the observed operation. Consequences carried honestly: copyCards now takes its index in the lane's full rendered space (the zones counted the originals, so the geometry's number is the writer's number — the old neighbour remap is deleted); resolveOperation freezes under the committed hold exactly as propose does, fixing a real bug where a settled within-board ⌥-copy drew the move arrangement until the echo and visibly re-shuffled. Stationary flips still wait for the next dropUpdated (their own Backlog card). Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -168,6 +168,15 @@ struct BoardDropContext {
|
||||
/// slot's uncapped reach past the last real lane lands *before* it. The delete a lane drag can
|
||||
/// propose over the column is not a slot at all — it is the column's own target answering
|
||||
/// (`retargetTrash`), and it names `.trash` rather than an index in this list.
|
||||
///
|
||||
/// **These zones never see a re-admitted lane, and that is an invariant rather than an
|
||||
/// accident.** `hiddenMembers` now leaves a copy's originals in the resting layout, but a lane
|
||||
/// can only be re-admitted into the strip it was picked up from, and a lane drag over its own
|
||||
/// board never resolves to `.copy` (`DragLocality.operation`'s first carve-out). A cross-board
|
||||
/// lane copy does re-admit — into the **source** strip, while the cursor is over the foreign
|
||||
/// board, where no zone of this board's is being consulted at all. So the strip's slot list is
|
||||
/// still "the lanes minus the run" every time this function actually runs, and `moveLanes` /
|
||||
/// `receiveLanes` keep the index space they always had.
|
||||
func retargetLanes() {
|
||||
guard session.isDraggingLanes, let cursor = stripCursor() else { return }
|
||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
||||
@@ -191,6 +200,13 @@ struct BoardDropContext {
|
||||
/// The single answer the lane's own drop delegate, the strip's fall-through, and the autoscroll
|
||||
/// driver all go through — "the lane's drop delegate and the autoscroll driver must go through
|
||||
/// one shared retarget, so they can never disagree" (DRAG-REORDER.md § Edge autoscroll).
|
||||
///
|
||||
/// **The zones are built over whatever `hiddenMembers` leaves standing**, which is what makes
|
||||
/// the proposal's index space follow the operation for free: a within-board move counts slots in
|
||||
/// the lane minus the dragged run, a within-board ⌥-copy counts them in the lane *including* the
|
||||
/// originals, because that is the layout the user is looking at. `DropSlotMath` has no opinion
|
||||
/// on the matter — the caller decides what the resting layout contains — and the commit's two
|
||||
/// writers are counted in exactly these two spaces (`BoardStore.moveCards` / `copyCards`).
|
||||
func retargetCards(inLane laneID: ItemID) {
|
||||
guard session.isDraggingCards, let cursor = globalCursor() else { return }
|
||||
guard let lane = store.snapshot.lanes.first(where: { $0.id == laneID }) else {
|
||||
@@ -644,6 +660,12 @@ struct BoardDropContext {
|
||||
// restore *is* the within-board move and a cross-board restore *is* the ordinary
|
||||
// arrival — which is exactly what retiring the restore-specific machinery bought.
|
||||
if within {
|
||||
// **Each writer is counted in the layout its own gesture was showing.** The zones
|
||||
// that produced `target.index` were built over the resting layout for *this*
|
||||
// operation (`retargetCards` → `DragSession.hiddenMembers`): a move's excludes the
|
||||
// dragged run, a copy's includes the originals, because a copy leaves them there.
|
||||
// So the number goes to each writer unrewritten and the drop lands where the shadow
|
||||
// was — the two index spaces differ, and neither writer has to guess which it got.
|
||||
if operation == .copy {
|
||||
store.copyCards(Set(ids), toLane: laneID, at: target.index)
|
||||
} else {
|
||||
|
||||
@@ -428,9 +428,11 @@ struct BoardView: View {
|
||||
/// The outer frame is always the snapped slot width, so the `HStack` lays the other lanes out
|
||||
/// 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.
|
||||
/// A lane being **dragged as a move** is simply absent from the strip: it is lifted out of the
|
||||
/// resting layout at pickup and stays out until release, while the system drag session carries
|
||||
/// its replica. A lane being dragged as a **copy** stays in the strip instead, because the copy
|
||||
/// leaves it there (DRAG-REORDER.md § Resting-layout zones) — which on this board means the
|
||||
/// cross-board case alone, ⌥ being ignored by a within-board lane drag.
|
||||
@ViewBuilder
|
||||
private func laneSlot(_ lane: Lane, standard: CGFloat) -> some View {
|
||||
let resizing = resize.isResizing(lane.id)
|
||||
@@ -628,8 +630,9 @@ 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 when the release
|
||||
/// would be a move, left standing when it would be a copy (`DragSession.hiddenMembers`) — with
|
||||
/// the shadows opened at the proposal.
|
||||
private var stripSlots: [StripSlot] {
|
||||
let session = appModel.dragSession
|
||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
||||
|
||||
@@ -212,10 +212,14 @@ struct CardFaceView: View {
|
||||
// genuinely fires on the trash side too: "⌘X works — cut in the trash, paste into a lane is
|
||||
// the keyboard-native restore" (04 ▸ The trash, resettled 2026-07-28).
|
||||
.cutTreatment(of: card.id, in: store)
|
||||
// The face being dragged out dims the same way while the session is in flight on the trash
|
||||
// side, where the source stays visible: a restore is not a removal until the write lands.
|
||||
// (On the board a dragged card is lifted out of the resting layout entirely, so this never
|
||||
// has anything to act on there — `LaneView.renderedCards`.)
|
||||
// The face being dragged out dims the same way while the session is in flight, wherever the
|
||||
// source stays visible — which is now two places rather than one. On the **trash** side a
|
||||
// restore is not a removal until the write lands, so the row never leaves. On the **board**
|
||||
// side a drag whose effective operation is `.copy` leaves its originals in the resting
|
||||
// layout, because the copy leaves them there (`DragSession.hiddenMembers`), and this is what
|
||||
// marks them as the source of the drag rather than as ordinary neighbours. A `.move`'s
|
||||
// originals are lifted out entirely and this has nothing to act on
|
||||
// (`LaneView.renderedCards`).
|
||||
.opacity(drops.session.isDragging(card.id) ? ClipboardTreatment.dimmedOpacity : 1)
|
||||
.contentShape(Rectangle())
|
||||
// **A card is one flattened accessibility element** (10-accessibility.md ▸ The board through
|
||||
|
||||
@@ -379,18 +379,33 @@ final class DragSession {
|
||||
///
|
||||
/// Only the source board hides anything, and only for a board-side session: a trash card's drag
|
||||
/// carries items that render in the trash column, not in any lane's masonry, so no lane loses a
|
||||
/// card to it.
|
||||
/// card to it. Those rows dim in place instead (`CardFaceView`, `TrashLaneRowView`), which is
|
||||
/// the same treatment a re-admitted board original now wears — one vocabulary for "this is the
|
||||
/// source of the drag", wherever the source stays visible.
|
||||
///
|
||||
/// **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.
|
||||
/// **A move lifts the run out; a copy leaves it in** (04-interactions.md ▸ Drag and drop:
|
||||
/// "originals stay", for the within-board ⌥-drag and for the cross-board default alike — ruled
|
||||
/// 2026-08-01, retiring the operation-blind carve-out this used to be). The layout on screen is
|
||||
/// therefore always the arrangement the release would produce: a copy's source board shows what
|
||||
/// it will still hold, a move's shows what it will have given away. Flipping ⌥ or ⌘ mid-drag
|
||||
/// reflows the source board once, and that one-shot reflow *is* the feedback the modifier is
|
||||
/// asking for — the previous rule's fear of "flapping" traded the answer to "what will this
|
||||
/// drop leave behind?" for a stillness nobody asked for.
|
||||
///
|
||||
/// Reading `operation` here is what makes the flip visible at all: the property is observed, so
|
||||
/// every surface that builds a resting layout off this method re-renders when it changes. The
|
||||
/// flip lands on the next `dropUpdated`, since that is where the operation is re-resolved; a
|
||||
/// modifier pressed with the mouse perfectly still waits for the next motion (Backlog ▸
|
||||
/// Stationary modifier flips).
|
||||
///
|
||||
/// **The hold freezes the answer**, because `resolveOperation` refuses to move once a release
|
||||
/// has settled: a settled copy keeps its originals on screen and a settled move keeps them
|
||||
/// lifted, in both cases until the echo snapshot lands and the real faces take over.
|
||||
func hiddenMembers(onBoardRooted root: URL) -> Set<ItemID> {
|
||||
guard isActive, container == .board, let sourceRoot,
|
||||
DragLocality.isSameBoard(root, sourceRoot)
|
||||
else { return [] }
|
||||
return memberSet
|
||||
return operation == .copy ? [] : memberSet
|
||||
}
|
||||
|
||||
/// The proposal's index when it names this board's strip, else `nil` — the lane strip's shadow
|
||||
@@ -575,14 +590,27 @@ final class DragSession {
|
||||
|
||||
/// Re-resolves the effective operation against the board under the cursor and the modifiers
|
||||
/// **right now**, and hands it back for the `DropProposal` the badge tracks.
|
||||
///
|
||||
/// **A settled release freezes it**, `propose`'s guard for a reason that is now the same one:
|
||||
/// the operation decides what the source board's resting layout holds (`hiddenMembers`) as well
|
||||
/// as which write went out, so a stray `dropUpdated` arriving after the commit — with the user's
|
||||
/// finger already off ⌥ — would re-lift originals the write is about to leave in place. The
|
||||
/// write named an operation; the overlay draws that operation until the echo lands.
|
||||
///
|
||||
/// `modifiers` defaults to the live flags, which is the only thing the app ever passes; it is a
|
||||
/// parameter so the resolution is checkable without a keyboard, exactly as `DragLocality`'s own
|
||||
/// pure function is.
|
||||
@discardableResult
|
||||
func resolveOperation(destinationRoot: URL) -> TransferOperation {
|
||||
guard let kind, let sourceRoot else { return operation }
|
||||
func resolveOperation(
|
||||
destinationRoot: URL,
|
||||
modifiers: NSEvent.ModifierFlags = NSEvent.modifierFlags
|
||||
) -> TransferOperation {
|
||||
guard let kind, let sourceRoot, hold == nil else { return operation }
|
||||
let resolved = DragLocality.operation(
|
||||
kind: kind,
|
||||
container: container,
|
||||
isWithinBoard: DragLocality.isSameBoard(sourceRoot, destinationRoot),
|
||||
modifiers: NSEvent.modifierFlags
|
||||
modifiers: modifiers
|
||||
)
|
||||
if resolved != operation { operation = resolved }
|
||||
return resolved
|
||||
@@ -609,10 +637,13 @@ 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
|
||||
/// 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.
|
||||
/// Everything that drives the rendering — the members, the proposal, the source root, the
|
||||
/// operation — 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
|
||||
/// exactly where the operation put them — lifted out for a move, standing dimmed in place for a
|
||||
/// copy — until the snapshot carrying the write arrives and the real faces take their place.
|
||||
/// Freezing the operation is `resolveOperation`'s own guard; the hold this method arms is what
|
||||
/// that guard reads.
|
||||
func commit(into store: BoardStore) {
|
||||
guard isActive else { return }
|
||||
sourceStore?.transient.dragMembers = .empty
|
||||
|
||||
@@ -859,11 +859,13 @@ struct LaneView: View {
|
||||
/// `lane.cards` and the trash column renders it instead. The tombstone era's ancestor walk and
|
||||
/// effective-liveness predicate are retired with the flag they read.
|
||||
///
|
||||
/// **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 being dragged *as a move* renders nowhere, for as long as the session lasts.** It is
|
||||
/// lifted out of the resting layout at pickup and stays out until release. A card being dragged
|
||||
/// as a **copy** stays exactly where it is and dims (`CardFaceView`), because that is what the
|
||||
/// copy will leave behind — so flipping ⌥ or ⌘ mid-drag reflows this lane once, which is the
|
||||
/// feedback the modifier is asking for (DRAG-REORDER.md § Resting-layout zones, ruled
|
||||
/// 2026-08-01). Either way the drop's index is counted in the layout on screen, which is why
|
||||
/// `copyCards` and `moveCards` take their index in different spaces.
|
||||
///
|
||||
/// **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
|
||||
|
||||
Reference in New Issue
Block a user