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:
2026-08-01 08:07:54 -04:00
parent adf4fc5973
commit 8206a78ecc
9 changed files with 383 additions and 51 deletions
+203
View File
@@ -654,3 +654,206 @@ struct DropSettleTests {
#expect(session.hold == nil)
}
}
// MARK: - What the source board's resting layout holds
/// **The resting layout follows the effective operation** (DRAG-REORDER.md § Resting-layout zones,
/// ruled 2026-08-01; 04-interactions.md Drag and drop: "originals stay" for both copies): a move
/// lifts the dragged run out of the source board, a copy leaves it standing there, because that is
/// what the release will actually leave behind. The whole rule is `hiddenMembers(onBoardRooted:)`,
/// and every surface that builds a resting layout the lane masonries, the strip, the drop zones
/// reads it, so pinning it here pins all of them.
///
/// The modifiers are passed to `resolveOperation` rather than held down: it is the same seam
/// `DragLocalityTests` uses one level down, and it is what makes the flip checkable without a
/// keyboard.
@MainActor
@Suite("The source board's resting layout")
struct DragRestingLayoutTests {
private static let lane1 = ItemID(rawValue: Ident.lane1)
private static let card1 = ItemID(rawValue: Ident.card1)
private static let card2 = ItemID(rawValue: Ident.card2)
private let none: NSEvent.ModifierFlags = []
private let option: NSEvent.ModifierFlags = [.option]
private let command: NSEvent.ModifierFlags = [.command]
/// Another board entirely the right-hand side of every cross-board resolution below. It needs
/// no fixture: locality compares paths, and nothing here reads the foreign board's contents.
private let elsewhere = URL(fileURLWithPath: "/Boards/Elsewhere.kanban", isDirectory: true)
/// One lane holding two cards, plus a trashed card so a `.trash`-container session has something
/// real to name.
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(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "Trashed"))
return fixture
}
/// A card session over `members`, picked up out of `lane1`.
private func cardSession(
_ store: BoardStore,
members: [ItemID] = [card1],
container: ItemContainer = .board
) -> DragSession {
let session = DragSession()
session.beginCards(
members,
folders: members.map {
store.rootURL
.appendingPathComponent(Ident.lane1, isDirectory: true)
.appendingPathComponent($0.rawValue, isDirectory: true)
},
heights: members.map { _ in 44 },
container: container,
source: store
)
return session
}
// MARK: The two layouts
@Test("A move lifts the dragged run out — the source board shows what it is giving away")
func aMoveLiftsTheRunOut() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = cardSession(store, members: [Self.card1, Self.card2])
#expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: none) == .move)
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1, Self.card2])
// over a foreign board is the cross-board move, and it lifts them out just the same.
#expect(session.resolveOperation(destinationRoot: elsewhere, modifiers: command) == .move)
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1, Self.card2])
}
/// The card the user filed: says "leave the originals here", so the originals are drawn here.
@Test("A copy hides nothing — the originals re-admit into the source board's layout")
func aCopyReAdmitsTheOriginals() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = cardSession(store, members: [Self.card1, Self.card2])
// The within-board -copy.
#expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: option) == .copy)
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
// And the cross-board default, which is a copy with no modifier at all.
#expect(session.resolveOperation(destinationRoot: elsewhere, modifiers: none) == .copy)
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
}
/// The flip is a deliberate user action and the one-shot reflow is its feedback which means
/// the layout has to answer *both* ways, as many times as the user asks.
@Test("Flipping the modifier mid-drag re-admits and re-lifts, every time")
func theFlipGoesBothWays() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = cardSession(store)
for modifiers in [none, option, none, option] {
session.resolveOperation(destinationRoot: store.rootURL, modifiers: modifiers)
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
#expect(hidden == (modifiers == option ? [] : [Self.card1]))
}
}
// MARK: The two things the rule does not reach
/// The trash column's rows never lift, whatever the operation: they render in the column rather
/// than in a lane's masonry, so there is no resting layout of a lane's for them to leave. They
/// dim in place instead (`CardFaceView`, `TrashLaneRowView`).
@Test("A trash-container session hides nothing, under either operation")
func trashSessionsAreUnchanged() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = cardSession(store, members: [ItemID(rawValue: Ident.card3)], container: .trash)
#expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: none) == .move)
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
#expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: option) == .copy)
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
}
@Test("Only the source board hides anything, whatever the operation")
func onlyTheSourceBoardHides() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = cardSession(store)
session.resolveOperation(destinationRoot: elsewhere, modifiers: command)
#expect(session.hiddenMembers(onBoardRooted: elsewhere).isEmpty)
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1])
}
/// The lane carve-out, read as a layout claim: a within-board lane drag never resolves to
/// `.copy`, so the strip's zones under the cursor never see a re-admitted lane and `moveLanes`
/// keeps the index space it always had. A **cross-board** lane copy does re-admit into the
/// source strip, while the cursor is over the foreign board.
@Test("A within-board lane drag never re-admits; a cross-board lane copy does")
func laneSessionsFollowTheCarveOut() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = DragSession()
session.beginLanes(
[Self.lane1],
folders: [store.rootURL.appendingPathComponent(Ident.lane1, isDirectory: true)],
units: [1],
source: store
)
#expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: option) == .move)
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.lane1])
#expect(session.resolveOperation(destinationRoot: elsewhere, modifiers: none) == .copy)
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
}
// MARK: The hold freezes it
/// A release settles the arrangement, and the operation is part of the arrangement now: the
/// write that went out named one, and a stray `dropUpdated` sampling a modifier the user has
/// already let go of must not redraw the board against the other one.
@Test("A settled copy keeps its originals on screen, whatever the modifiers do next")
func aSettledCopyStaysReAdmitted() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = cardSession(store)
session.resolveOperation(destinationRoot: store.rootURL, modifiers: option)
session.commit(into: store)
#expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: none) == .copy)
#expect(session.resolveOperation(destinationRoot: elsewhere, modifiers: command) == .copy)
#expect(session.operation == .copy)
#expect(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
}
@Test("A settled move keeps its originals lifted, whatever the modifiers do next")
func aSettledMoveStaysLifted() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = cardSession(store)
session.resolveOperation(destinationRoot: store.rootURL, modifiers: none)
session.commit(into: store)
#expect(session.resolveOperation(destinationRoot: store.rootURL, modifiers: option) == .move)
#expect(session.operation == .move)
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1])
}
}