Back out the render-at-release drop presentation
The settle rule 3f4125e introduced — at release the shadow swaps for the
dropped card(s) drawn in place immediately — is withdrawn on review. The
board returns to the presentation the committed-overlay hold has always
had: the shadows keep standing at their landing slot and the originals
keep their lift until the echo reload lands, whatever the operation was,
and the real faces arrive with the snapshot that carries the write.
Gone with it: the DropLanding/DroppedItem seam and the three landing
accessors, the hold's landing/operation payload and the removesOriginals
and keepsIdentity halves it decided, the pickup-captured titles a
cross-board arrival's face needed, LaneSlot.dropped with DroppedFace and
DroppedCardFace, the strip's dropped-lane run, and the trash column's
settled tombstone rows — the same presentation at the delete gesture's
end, which goes for the same reason while the drop-to-delete write and
its shadow row stay exactly as they were.
Kept: the hold itself, which predates the commit, along with the 1500 ms
failed-write timeout, its injectable duration and its extracted expire,
and the guard that keeps a settled release past retargeting. The tests
pinning those stay; the ones asserting what the landing slot drew are
gone, and the suite now pins the hold's own claim — the arrangement does
not move at the release.
The pause between the release and the echo is therefore an open design
question again, filed on the Redesign board.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -502,13 +502,10 @@ struct BoardDropContext {
|
|||||||
/// bracket (`BoardStore.deleteByDrag`), so a card deleted by drop is indistinguishable on disk
|
/// 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).
|
/// 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
|
/// The write is the first half; the second is the **committed-overlay hold**
|
||||||
/// still in flight when this returns, so the session flips from proposing to committed and the
|
/// (`DragSession.commit`). The write is still in flight when this returns, so the session flips
|
||||||
/// slot the shadows were holding starts drawing the dropped cards themselves — "at release the
|
/// from proposing to committed and keeps drawing the arrangement it was showing — the shadows at
|
||||||
/// shadow is replaced by the dropped card(s) drawn in place immediately, the appear never waiting
|
/// their landing slots, the originals lifted out — until this store's echo reload lands.
|
||||||
/// 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 {
|
func commitDrop() -> Bool {
|
||||||
guard session.isActive, let kind = session.kind, let sourceRoot = session.sourceRoot else {
|
guard session.isActive, let kind = session.kind, let sourceRoot = session.sourceRoot else {
|
||||||
return false
|
return false
|
||||||
@@ -598,9 +595,8 @@ struct BoardDropContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The committed-overlay hold: keep drawing the arrangement — the dropped cards included —
|
// The committed-overlay hold: keep drawing the arrangement until this store's next snapshot.
|
||||||
// until this store's next snapshot.
|
session.commit(into: store)
|
||||||
session.commit(into: store, survivors: survivors, operation: operation)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -374,11 +374,8 @@ struct BoardView: View {
|
|||||||
/// off the tidy snapped layout regardless of the live overflow.
|
/// 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
|
/// 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
|
/// layout at pickup and stays out until release, whatever the effective operation is
|
||||||
/// operation is (DRAG-REORDER.md § Resting-layout zones), while the system drag session carries
|
/// (DRAG-REORDER.md § Resting-layout zones), while the system drag session carries its replica.
|
||||||
/// 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
|
@ViewBuilder
|
||||||
private func laneSlot(_ lane: Lane, standard: CGFloat) -> some View {
|
private func laneSlot(_ lane: Lane, standard: CGFloat) -> some View {
|
||||||
let resizing = resize.isResizing(lane.id)
|
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
|
/// What the strip lays out: the resting lanes — the dragged run lifted out, whatever the
|
||||||
/// flight — with the run opened at the proposal.
|
/// effective operation is — with the shadows opened at the proposal.
|
||||||
private var stripSlots: [StripSlot] {
|
private var stripSlots: [StripSlot] {
|
||||||
let session = appModel.dragSession
|
let session = appModel.dragSession
|
||||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
||||||
var slots = liveLanes.filter { !hidden.contains($0.id) }.map(StripSlot.lane)
|
var slots = liveLanes.filter { !hidden.contains($0.id) }.map(StripSlot.lane)
|
||||||
guard let landing = session.laneLanding(onBoardRooted: store.rootURL) else { return slots }
|
guard let index = stripProposal else { return slots }
|
||||||
slots.insert(contentsOf: runSlots(landing, session: session), at: min(max(0, landing.index), slots.count))
|
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
|
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
|
// MARK: - Grammar keys
|
||||||
|
|
||||||
/// **Return**, narrowly (04-interactions.md ▸ Grammar): a sole selected live card begins an
|
/// **Return**, narrowly (04-interactions.md ▸ Grammar): a sole selected live card begins an
|
||||||
|
|||||||
@@ -144,61 +144,8 @@ struct FileDropTarget: Equatable, Sendable {
|
|||||||
|
|
||||||
// MARK: - The committed-overlay hold
|
// 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 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.
|
||||||
/// 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
|
/// 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
|
/// 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
|
/// app-mediated echo is normally next, and a foreign one that lands first re-grounds everything
|
||||||
/// anyway.
|
/// 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
|
/// 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
|
/// 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.
|
/// 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.
|
/// That board's `snapshotGeneration` at the moment of the commit.
|
||||||
var generation: Int
|
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
|
/// 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
|
/// a watcher round trip, short enough that a refused write does not leave the board drawing an
|
||||||
/// arrangement it never got.
|
/// 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.
|
/// The dragged items' folders, aligned 1:1 with `members` — what the cross-board commits take.
|
||||||
@ObservationIgnored private(set) var folders: [URL] = []
|
@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
|
/// 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
|
/// away with its window mid-drag while the root — the left-hand side of the locality
|
||||||
/// comparison — stays perfectly usable.
|
/// comparison — stays perfectly usable.
|
||||||
@@ -432,9 +346,8 @@ final class DragSession {
|
|||||||
var isDraggingCards: Bool { kind == .cards }
|
var isDraggingCards: Bool { kind == .cards }
|
||||||
var isDraggingLanes: Bool { kind == .lanes }
|
var isDraggingLanes: Bool { kind == .lanes }
|
||||||
|
|
||||||
/// Whether the release has **settled**: the write is issued, the proposal is being held as
|
/// Whether the release has **settled**: the write is issued and the arrangement the session was
|
||||||
/// overlay state, and every surface that was drawing shadows is now drawing the dropped items
|
/// showing is being held as overlay state until the echo reload lands (`CommittedHold`).
|
||||||
/// (`CommittedHold`).
|
|
||||||
var isSettled: Bool { hold != nil }
|
var isSettled: Bool { hold != nil }
|
||||||
|
|
||||||
/// N — the number of contiguous shadows the proposal draws.
|
/// 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
|
/// carries items that render in the quasi-lane, not in any lane's masonry, so no lane loses a
|
||||||
/// card to it.
|
/// card to it.
|
||||||
///
|
///
|
||||||
/// **The dragged run is lifted out whatever the effective operation is** *while the drag is in
|
/// **The dragged run is lifted out whatever the effective operation is** (DRAG-REORDER.md §
|
||||||
/// flight* (DRAG-REORDER.md § Resting-layout zones): ⌥ can be pressed and released mid-drag, and
|
/// Resting-layout zones): ⌥ can be pressed and released mid-drag, and a layout that re-admitted
|
||||||
/// a layout that re-admitted the originals on every modifier flip would flap the whole board
|
/// the originals on every modifier flip would flap the whole board under the cursor. The copy's
|
||||||
/// under the cursor.
|
/// 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.
|
||||||
/// **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`).
|
|
||||||
func hiddenMembers(onBoardRooted root: URL) -> Set<ItemID> {
|
func hiddenMembers(onBoardRooted root: URL) -> Set<ItemID> {
|
||||||
guard isActive, side == .live, let sourceRoot,
|
guard isActive, side == .live, let sourceRoot,
|
||||||
DragLocality.isSameBoard(root, sourceRoot)
|
DragLocality.isSameBoard(root, sourceRoot)
|
||||||
else { return [] }
|
else { return [] }
|
||||||
if let hold, !hold.removesOriginals { return [] }
|
|
||||||
return memberSet
|
return memberSet
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,54 +405,6 @@ final class DragSession {
|
|||||||
return proposal.index
|
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
|
/// 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
|
/// 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
|
/// last one goes the drag has emptied itself. Partial vanishing drops the survivors, matching
|
||||||
@@ -629,19 +482,18 @@ final class DragSession {
|
|||||||
func beginCards(
|
func beginCards(
|
||||||
_ members: [ItemID],
|
_ members: [ItemID],
|
||||||
folders: [URL],
|
folders: [URL],
|
||||||
titles: [String?],
|
|
||||||
heights: [CGFloat],
|
heights: [CGFloat],
|
||||||
side: Liveness,
|
side: Liveness,
|
||||||
source: BoardStore
|
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
|
cardHeights = heights
|
||||||
laneUnits = []
|
laneUnits = []
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Begins a lane session.
|
/// Begins a lane session.
|
||||||
func beginLanes(_ members: [ItemID], folders: [URL], titles: [String?], units: [Int], source: BoardStore) {
|
func beginLanes(_ members: [ItemID], folders: [URL], units: [Int], source: BoardStore) {
|
||||||
begin(kind: .lanes, members: members, folders: folders, titles: titles, side: .live, source: source)
|
begin(kind: .lanes, members: members, folders: folders, side: .live, source: source)
|
||||||
laneUnits = units
|
laneUnits = units
|
||||||
cardHeights = []
|
cardHeights = []
|
||||||
}
|
}
|
||||||
@@ -650,7 +502,6 @@ final class DragSession {
|
|||||||
kind: DragKind,
|
kind: DragKind,
|
||||||
members: [ItemID],
|
members: [ItemID],
|
||||||
folders: [URL],
|
folders: [URL],
|
||||||
titles: [String?],
|
|
||||||
side: Liveness,
|
side: Liveness,
|
||||||
source: BoardStore
|
source: BoardStore
|
||||||
) {
|
) {
|
||||||
@@ -659,7 +510,6 @@ final class DragSession {
|
|||||||
self.members = members
|
self.members = members
|
||||||
self.memberSet = Set(members)
|
self.memberSet = Set(members)
|
||||||
self.folders = folders
|
self.folders = folders
|
||||||
self.titles = titles
|
|
||||||
self.side = side
|
self.side = side
|
||||||
self.sourceStore = source
|
self.sourceStore = source
|
||||||
self.sourceRoot = source.rootURL
|
self.sourceRoot = source.rootURL
|
||||||
@@ -703,7 +553,6 @@ final class DragSession {
|
|||||||
members = []
|
members = []
|
||||||
memberSet = []
|
memberSet = []
|
||||||
folders = []
|
folders = []
|
||||||
titles = []
|
|
||||||
cardHeights = []
|
cardHeights = []
|
||||||
laneUnits = []
|
laneUnits = []
|
||||||
proposal = nil
|
proposal = nil
|
||||||
@@ -718,32 +567,16 @@ final class DragSession {
|
|||||||
/// Enters the committed phase: the arrangement the session was showing stays on screen until
|
/// Enters the committed phase: the arrangement the session was showing stays on screen until
|
||||||
/// `store` applies its next snapshot (`CommittedHold`).
|
/// `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
|
/// 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
|
/// mechanism: the shadows stay at the landing slots and the originals stay lifted out until the
|
||||||
/// over, and `survivors` — the run this drop actually wrote, vanished members already dropped —
|
/// snapshot carrying the write arrives and the real faces take their place.
|
||||||
/// becomes the hold's `landing`, drawn there as ordinary card faces (`DropLanding`). A copy's
|
func commit(into store: BoardStore) {
|
||||||
/// 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 }
|
guard isActive else { return }
|
||||||
sourceStore?.transient.dragMembers = .empty
|
sourceStore?.transient.dragMembers = .empty
|
||||||
watchdog?.cancel()
|
watchdog?.cancel()
|
||||||
watchdog = nil
|
watchdog = nil
|
||||||
let hold = CommittedHold(
|
let hold = CommittedHold(boardRoot: store.rootURL, generation: store.snapshotGeneration)
|
||||||
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
|
self.hold = hold
|
||||||
let timeout = holdTimeout
|
let timeout = holdTimeout
|
||||||
holdTimeoutTask?.cancel()
|
holdTimeoutTask?.cancel()
|
||||||
|
|||||||
@@ -12,12 +12,6 @@ import SwiftUI
|
|||||||
/// **Hit-transparent, always.** The strip's own drop target has to stay live beneath the shadows
|
/// **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
|
/// (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.
|
/// 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 {
|
struct DragShadow: View {
|
||||||
|
|
||||||
/// Matched to the surface it stands in for — a lane's plate is 10, a card's is 8.
|
/// Matched to the surface it stands in for — a lane's plate is 10, a card's is 8.
|
||||||
|
|||||||
+19
-197
@@ -377,9 +377,6 @@ struct LaneView: View {
|
|||||||
drops.session.beginLanes(
|
drops.session.beginLanes(
|
||||||
members.map(\.id),
|
members.map(\.id),
|
||||||
folders: payload.folders,
|
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
|
// The dragged items' own sizes, frozen at drag start — the one thing that is
|
||||||
// (03-board-ui.md § Motion).
|
// (03-board-ui.md § Motion).
|
||||||
units: members.map { LaneLayoutMath.displayUnits(of: $0) },
|
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.
|
// height — the run's real footprint, so the drop lands exactly here.
|
||||||
DragShadow(cornerRadius: 8)
|
DragShadow(cornerRadius: 8)
|
||||||
.frame(height: height)
|
.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 …)"
|
// "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
|
/// Where a card drag would land **in this lane**, or `nil` when the proposal is elsewhere.
|
||||||
/// drag is in flight, the dropped cards themselves once the release has settled
|
private var cardProposal: Int? {
|
||||||
/// (`DragSession.cardLanding`). `nil` when the proposal is elsewhere.
|
drops.session.laneProposal(onBoardRooted: store.rootURL, laneID: lane.id)
|
||||||
private var cardLanding: DropLanding? {
|
|
||||||
drops.session.cardLanding(onBoardRooted: store.rootURL, laneID: lane.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The run's **geometry** — where it opens and what each of its slots is worth in height — or
|
/// The shadow run this lane opens, or `nil` when no proposal names it — the masonry's one
|
||||||
/// `nil` when no proposal names this lane. The masonry's one make-room mechanism, and the
|
/// make-room mechanism, and the reflow's narrow animation key.
|
||||||
/// reflow's narrow animation key.
|
|
||||||
///
|
///
|
||||||
/// Two sessions feed it and they are mutually exclusive by construction (a file session never
|
/// 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):
|
/// 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;
|
/// drop lands exactly where the shadows are;
|
||||||
/// - **a Finder file drag**, at the nominal height, one shadow per file — the cards being
|
/// - **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.
|
/// 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? {
|
private var shadowRun: ShadowRun? {
|
||||||
if let cardLanding {
|
if let position = cardProposal {
|
||||||
return ShadowRun(position: cardLanding.index, heights: drops.session.cardHeights)
|
return ShadowRun(position: position, heights: drops.session.cardHeights)
|
||||||
}
|
}
|
||||||
if let proposal = drops.session.fileLaneProposal(onBoardRooted: store.rootURL, laneID: lane.id) {
|
if let proposal = drops.session.fileLaneProposal(onBoardRooted: store.rootURL, laneID: lane.id) {
|
||||||
return ShadowRun(
|
return ShadowRun(
|
||||||
@@ -625,15 +610,11 @@ struct LaneView: View {
|
|||||||
/// arriving card's identity so the handoff is one arrival rather than two (`LaneSlot`). The
|
/// 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
|
/// position math above is untouched by that — the key changes, the index does not — so the
|
||||||
/// masonry cannot flinch at the moment of commit.
|
/// 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] {
|
private var slots: [LaneSlot] {
|
||||||
var result = renderedCards.map(LaneSlot.card)
|
var result = renderedCards.map(LaneSlot.card)
|
||||||
|
|
||||||
let run = shadowRun
|
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)?
|
var placeholder: (position: Int, phase: NewCardPlaceholder.Phase)?
|
||||||
if let pending = store.transient.newCardPlaceholder, pending.laneID == lane.id {
|
if let pending = store.transient.newCardPlaceholder, pending.laneID == lane.id {
|
||||||
let position = BoardStore.insertionIndex(after: pending.anchorCardID, among: renderedCards)
|
let position = BoardStore.insertionIndex(after: pending.anchorCardID, among: renderedCards)
|
||||||
@@ -641,72 +622,30 @@ struct LaneView: View {
|
|||||||
placeholder = (position, pending.phase)
|
placeholder = (position, pending.phase)
|
||||||
}
|
}
|
||||||
|
|
||||||
let inserted = runSlots(run)
|
let heights = run?.heights ?? []
|
||||||
if let runPosition {
|
if let shadowPosition {
|
||||||
result.insert(contentsOf: inserted, at: runPosition)
|
let shadows = heights.enumerated().map { LaneSlot.shadow(index: $0.offset, height: $0.element) }
|
||||||
|
result.insert(contentsOf: shadows, at: shadowPosition)
|
||||||
}
|
}
|
||||||
if var placeholder {
|
if var placeholder {
|
||||||
if let runPosition, placeholder.position >= runPosition {
|
if let shadowPosition, placeholder.position >= shadowPosition {
|
||||||
placeholder.position += inserted.count
|
placeholder.position += heights.count
|
||||||
}
|
}
|
||||||
result.insert(.placeholder(placeholder.phase), at: min(placeholder.position, result.count))
|
result.insert(.placeholder(placeholder.phase), at: min(placeholder.position, result.count))
|
||||||
}
|
}
|
||||||
return result
|
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
|
/// **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
|
/// 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
|
/// liveness). The lane half of that rule is `BoardView`'s, which never builds a `LaneView` for a
|
||||||
/// tombstoned lane at all.
|
/// tombstoned lane at all.
|
||||||
///
|
///
|
||||||
/// **A dragged card renders nowhere either, for as long as the drag is in flight.** It is lifted
|
/// **A dragged card renders nowhere either, for as long as the session lasts.** It is lifted out
|
||||||
/// out of the resting layout at pickup and stays out until release *whatever the effective
|
/// of the resting layout at pickup and stays out until release *whatever the effective operation
|
||||||
/// operation is* — a ⌥-copy's originals really do stay, but ⌥ can be pressed and released
|
/// is* — a ⌥-copy's originals really do stay, but ⌥ can be pressed and released mid-drag, and a
|
||||||
/// mid-drag, and a layout that re-admitted them on every flip would flap the board under the
|
/// layout that re-admitted them on every flip would flap the board under the cursor
|
||||||
/// cursor (DRAG-REORDER.md § Resting-layout zones).
|
/// (DRAG-REORDER.md § Resting-layout zones). The copy's originals reappear when the write lands.
|
||||||
///
|
|
||||||
/// **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):
|
/// **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
|
/// "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)
|
case placeholder(NewCardPlaceholder.Phase)
|
||||||
/// One of a drag's N contiguous shadows, at the dragged card's frozen height.
|
/// One of a drag's N contiguous shadows, at the dragged card's frozen height.
|
||||||
case shadow(index: Int, height: CGFloat)
|
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 {
|
var id: String {
|
||||||
switch self {
|
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
|
// Constant per position in the run, so the shadows animate as slides when the proposal moves
|
||||||
// rather than blinking out and back in.
|
// rather than blinking out and back in.
|
||||||
case let .shadow(index, _): "shadow:\(index)"
|
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
|
// MARK: - The card plate's metrics
|
||||||
|
|
||||||
/// The card plate's geometry, spelled once because **two views draw it**: the real face
|
/// 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(
|
drops.session.beginCards(
|
||||||
ordered,
|
ordered,
|
||||||
folders: payload.folders,
|
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
|
// 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
|
// replica, and its lingering "last measured frame" would mis-size the shadow and the
|
||||||
// span-cap (03-board-ui.md § Motion).
|
// 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
|
// MARK: - The inline title field
|
||||||
|
|
||||||
/// The one text field all three inline editors wear — the new-card placeholder, a card rename, and
|
/// The one text field all three inline editors wear — the new-card placeholder, a card rename, and
|
||||||
|
|||||||
@@ -127,63 +127,26 @@ struct TrashLaneView: View {
|
|||||||
|
|
||||||
// MARK: - The delete gesture's landing
|
// MARK: - The delete gesture's landing
|
||||||
|
|
||||||
/// What the column draws at the drop proposal — the shadow run while the drag is in flight, the
|
/// Where the delete gesture's shadow run opens in this column — always the topmost row — or `nil`
|
||||||
/// tombstoned rows themselves once the release has settled (`DragSession.trashLanding`). `nil`
|
|
||||||
/// when no proposal names the trash, which is every other moment of the app's life.
|
/// when no proposal names the trash, which is every other moment of the app's life.
|
||||||
private var landing: DropLanding? {
|
private var proposal: Int? {
|
||||||
drops.session.trashLanding(onBoardRooted: store.rootURL)
|
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 run stands until the echo reload brings the real tombstones — the committed-overlay hold
|
||||||
/// the echo has landed but the hold has not yet been retired (`BoardView` hands off on the *next*
|
/// keeps the arrangement the release proposed on screen for that round trip, exactly as every
|
||||||
/// snapshot, an `onChange` later), the arriving card is in both collections at once — so the real
|
/// other container's does (`CommittedHold`).
|
||||||
/// 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).
|
|
||||||
private var slots: [TrashSlot] {
|
private var slots: [TrashSlot] {
|
||||||
guard let landing else { return entries.map(TrashSlot.entry) }
|
var result = entries.map(TrashSlot.entry)
|
||||||
|
guard let proposal else { return result }
|
||||||
let arriving: [ItemID]
|
let run = (0..<drops.session.shadowCount).map(TrashSlot.shadow)
|
||||||
let run: [TrashSlot]
|
result.insert(contentsOf: run, at: min(max(0, proposal), result.count))
|
||||||
switch landing.run {
|
|
||||||
case .shadows:
|
|
||||||
arriving = []
|
|
||||||
run = (0..<drops.session.shadowCount).map(TrashSlot.shadow)
|
|
||||||
case let .dropped(drop):
|
|
||||||
arriving = drop.items.map(\.id)
|
|
||||||
run = drop.items.map(TrashSlot.dropped)
|
|
||||||
}
|
|
||||||
|
|
||||||
let displaced = Set(arriving)
|
|
||||||
var result: [TrashSlot] = entries.filter { !displaced.contains($0.id) }.map(TrashSlot.entry)
|
|
||||||
result.insert(contentsOf: run, at: min(max(0, landing.index), result.count))
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One dropped card, drawn as the trash row it is about to become.
|
|
||||||
///
|
|
||||||
/// The card is still **live in this board's snapshot** at this instant — the write is in flight —
|
|
||||||
/// so its real symbol and title are right there to draw, and the payload's title is the fallback
|
|
||||||
/// for the case that cannot happen here but costs one `??` to survive.
|
|
||||||
private func droppedRow(_ item: DroppedItem) -> 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
|
// MARK: - Header
|
||||||
|
|
||||||
/// Dimmed and hatched, with the trash symbol, the stable "Trash" title and a count badge
|
/// 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)
|
DragShadow(cornerRadius: 6)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(height: nominalRowHeight)
|
.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 —
|
// 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
|
// 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
|
// 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.
|
// 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
|
// `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 —
|
// 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
|
// 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
|
/// The trash row's *appearance*, with none of anybody's behaviour — the compact, dimmed plate a
|
||||||
/// tombstone wears.
|
/// 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
|
/// 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
|
/// 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
|
/// the row from the rubber band and the arrow keys).
|
||||||
/// 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.
|
|
||||||
private struct TrashRowPlate: View {
|
private struct TrashRowPlate: View {
|
||||||
|
|
||||||
let symbol: String
|
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).
|
/// One of the drag's N shadows, holding the topmost rows open (04-interactions.md ▸ The trash).
|
||||||
case shadow(index: Int)
|
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 {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
case let .entry(entry): Self.identity(of: entry.id)
|
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
|
// Constant per position in the run, so a run that grows or shrinks animates as slots rather
|
||||||
// than blinking (`LaneSlot`'s rule).
|
// than blinking (`LaneSlot`'s rule).
|
||||||
case let .shadow(index): "shadow:\(index)"
|
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(
|
drops.session.beginCards(
|
||||||
rows.map(\.id),
|
rows.map(\.id),
|
||||||
folders: payload.folders,
|
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 },
|
heights: rows.map { _ in LaneDropRegistry.nominalCardHeight },
|
||||||
side: .trashed,
|
side: .trashed,
|
||||||
source: store
|
source: store
|
||||||
|
|||||||
@@ -276,35 +276,22 @@ struct CommittedHoldTests {
|
|||||||
func theTimeoutFigure() {
|
func theTimeoutFigure() {
|
||||||
#expect(CommittedHold.timeout == .milliseconds(1500))
|
#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
|
/// **What the session renders between the release and the echo** (DRAG-REORDER.md § The
|
||||||
/// between the release and the echo. The claims here are the pure state the board's surfaces read —
|
/// committed-overlay hold): the write is in flight and the snapshot has not moved, so the session
|
||||||
/// what the proposal's slot draws (`DragSession.cardLanding`) and which originals stay lifted out
|
/// keeps drawing the arrangement it was showing — the shadows at their landing slots, the originals
|
||||||
/// (`hiddenMembers`) — so the whole ruling is checkable without a view: "at release the shadow is
|
/// lifted out — until the destination store applies its next snapshot, or the deadline says none is
|
||||||
/// replaced by the dropped card(s) drawn in place immediately … a lingering shadow over a hidden
|
/// coming. The claims here are the pure state the board's surfaces read (`laneProposal`,
|
||||||
/// card is the hold failing its one job".
|
/// `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
|
/// 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
|
/// 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.
|
/// standing in for exactly the thing under test. Nothing here writes.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Suite("The drop settle")
|
@Suite("The committed hold")
|
||||||
struct DropSettleTests {
|
struct DropSettleTests {
|
||||||
|
|
||||||
private static let lane1 = ItemID(rawValue: Ident.lane1)
|
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`.
|
/// 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(
|
private func proposing(
|
||||||
_ store: BoardStore,
|
_ store: BoardStore,
|
||||||
members: [(id: ItemID, title: String?)] = [(card1, "First")],
|
members: [(id: ItemID, title: String?)] = [(card1, "First")],
|
||||||
@@ -349,7 +339,6 @@ struct DropSettleTests {
|
|||||||
.appendingPathComponent(Ident.lane1, isDirectory: true)
|
.appendingPathComponent(Ident.lane1, isDirectory: true)
|
||||||
.appendingPathComponent($0.id.rawValue, isDirectory: true)
|
.appendingPathComponent($0.id.rawValue, isDirectory: true)
|
||||||
},
|
},
|
||||||
titles: members.map(\.title),
|
|
||||||
heights: members.map { _ in 44 },
|
heights: members.map { _ in 44 },
|
||||||
side: .live,
|
side: .live,
|
||||||
source: store
|
source: store
|
||||||
@@ -369,7 +358,7 @@ struct DropSettleTests {
|
|||||||
|
|
||||||
// MARK: In flight
|
// 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 {
|
func inFlightDrawsShadows() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
@@ -377,81 +366,42 @@ struct DropSettleTests {
|
|||||||
let session = proposing(store)
|
let session = proposing(store)
|
||||||
|
|
||||||
#expect(!session.isSettled)
|
#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])
|
#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")
|
/// "The session flips from proposing to committed and keeps rendering the arrangement it was
|
||||||
func settledMoveDrawsTheCard() throws {
|
/// 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()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let session = proposing(store)
|
let session = proposing(store)
|
||||||
|
|
||||||
session.commit(into: store, survivors: [0], operation: .move)
|
session.commit(into: store)
|
||||||
|
|
||||||
#expect(session.isSettled)
|
#expect(session.isSettled)
|
||||||
let landing = try #require(session.cardLanding(onBoardRooted: store.rootURL, laneID: Self.lane1))
|
#expect(session.laneProposal(onBoardRooted: store.rootURL, laneID: Self.lane1) == 2)
|
||||||
// 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])
|
#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")
|
@Test("A settled release is past retargeting: a late callback cannot move or withdraw it")
|
||||||
func settledProposalsAreFinal() throws {
|
func settledProposalsAreFinal() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let session = proposing(store)
|
let session = proposing(store)
|
||||||
session.commit(into: store, survivors: [0], operation: .move)
|
session.commit(into: store)
|
||||||
|
|
||||||
session.propose(nil)
|
session.propose(nil)
|
||||||
session.propose(DropTarget(boardRoot: store.rootURL, container: .lane(Self.lane1), index: 0))
|
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
|
// MARK: The trash's landing
|
||||||
@@ -476,44 +426,32 @@ struct DropSettleTests {
|
|||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let session = proposingIntoTheTrash(store)
|
let session = proposingIntoTheTrash(store)
|
||||||
|
|
||||||
let landing = try #require(session.trashLanding(onBoardRooted: store.rootURL))
|
#expect(session.trashProposal(onBoardRooted: store.rootURL) == 0)
|
||||||
#expect(landing.index == 0)
|
|
||||||
#expect(landing.run == .shadows)
|
|
||||||
// The proposal names one container and one only: the lane the cards came out of draws
|
// The proposal names one container and one only: the lane the cards came out of draws
|
||||||
// nothing, and neither does the strip.
|
// 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)
|
#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])
|
#expect(session.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The settle, at the one landing whose cards would otherwise wink out of existence: the write
|
/// The delete's own hold: the write takes the cards off the live side, and the shadow rows keep
|
||||||
/// takes them off the live side, so the trash has to draw them from the instant of release.
|
/// the space they landed in until the echo brings the real tombstones.
|
||||||
@Test("A settled trash drop draws the tombstoned rows on top and keeps the originals lifted")
|
@Test("A committed trash drop keeps its shadow rows on top and the originals lifted")
|
||||||
func trashSettleDrawsTheRows() throws {
|
func trashHoldKeepsTheRows() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let session = proposingIntoTheTrash(store, members: [(Self.card1, "First"), (Self.card2, "Second")])
|
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)
|
||||||
session.commit(into: store, survivors: [0, 1], operation: .move)
|
|
||||||
|
|
||||||
let landing = try #require(session.trashLanding(onBoardRooted: store.rootURL))
|
#expect(session.trashProposal(onBoardRooted: store.rootURL) == 0)
|
||||||
#expect(landing.index == 0, "the slot does not move at the settle — only what it contains")
|
#expect(session.shadowCount == 2)
|
||||||
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.hiddenMembers(onBoardRooted: store.rootURL) == [Self.card1, Self.card2])
|
#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 {
|
func laneSessionsHaveNoTrashLanding() throws {
|
||||||
let fixture = try makeBoard()
|
let fixture = try makeBoard()
|
||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
@@ -522,13 +460,12 @@ struct DropSettleTests {
|
|||||||
session.beginLanes(
|
session.beginLanes(
|
||||||
[Self.lane1],
|
[Self.lane1],
|
||||||
folders: [store.rootURL.appendingPathComponent(Ident.lane1, isDirectory: true)],
|
folders: [store.rootURL.appendingPathComponent(Ident.lane1, isDirectory: true)],
|
||||||
titles: ["Todo"],
|
|
||||||
units: [1],
|
units: [1],
|
||||||
source: store
|
source: store
|
||||||
)
|
)
|
||||||
session.propose(DropTarget(boardRoot: store.rootURL, container: .trash, index: 0))
|
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
|
// MARK: The hand-off
|
||||||
@@ -539,13 +476,13 @@ struct DropSettleTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let session = proposing(store)
|
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)
|
session.handOff(root: store.rootURL, generation: store.snapshotGeneration + 1)
|
||||||
|
|
||||||
#expect(!session.isSettled)
|
#expect(!session.isSettled)
|
||||||
#expect(!session.isActive)
|
#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(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,12 +492,12 @@ struct DropSettleTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let session = proposing(store)
|
let session = proposing(store)
|
||||||
session.commit(into: store, survivors: [0], operation: .move)
|
session.commit(into: store)
|
||||||
|
|
||||||
session.end()
|
session.end()
|
||||||
|
|
||||||
#expect(!session.isSettled)
|
#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(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
|
||||||
#expect(store.transient.dragMembers.ids.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.
|
// wall clock in the suite for a claim about the discard rather than about the clock.
|
||||||
session.holdTimeout = .milliseconds(20)
|
session.holdTimeout = .milliseconds(20)
|
||||||
|
|
||||||
session.commit(into: store, survivors: [0], operation: .move)
|
session.commit(into: store)
|
||||||
#expect(session.isSettled)
|
#expect(session.isSettled)
|
||||||
|
|
||||||
#expect(await settles { !session.isSettled }, "the deadline must dissolve an overlay with no hand-off coming")
|
#expect(await settles { !session.isSettled }, "the deadline must dissolve an overlay with no hand-off coming")
|
||||||
#expect(!session.isActive)
|
#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(session.hiddenMembers(onBoardRooted: store.rootURL).isEmpty)
|
||||||
#expect(store.transient.dragMembers.ids.isEmpty)
|
#expect(store.transient.dragMembers.ids.isEmpty)
|
||||||
}
|
}
|
||||||
@@ -598,7 +535,7 @@ struct DropSettleTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let session = proposing(store)
|
let session = proposing(store)
|
||||||
session.commit(into: store, survivors: [0], operation: .move)
|
session.commit(into: store)
|
||||||
let hold = try #require(session.hold)
|
let hold = try #require(session.hold)
|
||||||
|
|
||||||
session.expire(CommittedHold(boardRoot: store.rootURL, generation: 999))
|
session.expire(CommittedHold(boardRoot: store.rootURL, generation: 999))
|
||||||
@@ -616,7 +553,7 @@ struct DropSettleTests {
|
|||||||
let store = try BoardStore(rootURL: fixture.root)
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
let session = proposing(store)
|
let session = proposing(store)
|
||||||
session.holdTimeout = .milliseconds(20)
|
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 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.
|
// 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)
|
#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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user