RestingLayoutCache (session-scoped, @ObservationIgnored on DragSession, cleared at begin and end) holds each lane's resting layout as ids + heights — never [Card] — keyed on the hovered board's applied snapshotGeneration, the registry's new heightsGeneration, the lane, and the operation-following hidden set. The grid stays event-time on purpose (autoscroll moves a lane's origin with the snapshot standing still), so re-grounding rule 1 holds exactly: nothing survives a reload, only the per-sample repetition goes. The steady-state cost of a hover is now the containment scan plus four stores and a divide. Drag-perf suspect #3, card b9f48fd1. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
765 lines
39 KiB
Swift
765 lines
39 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import Observation
|
|
import SwiftUI
|
|
|
|
// MARK: - Where a drag would land
|
|
|
|
/// The drop proposal: which board, which container, which slot.
|
|
///
|
|
/// One value for every layout, because they differ only in what the container is, and `boardRoot` is
|
|
/// what makes a proposal cross-board-aware: only the board whose root it names renders the shadows,
|
|
/// and only that board's delegate may commit it.
|
|
struct DropTarget: Equatable, Sendable {
|
|
|
|
/// The three surfaces a drop can name, spelled as a sum so the impossible combinations cannot be
|
|
/// written down at all.
|
|
///
|
|
/// The trash is a case rather than an id because **it has no id**: `.trash/` "holds card
|
|
/// folders directly — same shape as a lane's children, no `index.md` of its own"
|
|
/// (01-storage-format.md § Deletion), so there is nothing to put in a `lane`, and its index is
|
|
/// not a position the pointer chose either (see `TrashDrop`).
|
|
enum Container: Equatable, Sendable {
|
|
/// The **lane strip**: the index counts the board's lanes with the dragged run removed.
|
|
case strip
|
|
/// That lane's **masonry**: the index is a position in its logical card order
|
|
/// (DRAG-REORDER.md § The card masonry).
|
|
case lane(ItemID)
|
|
/// The **trash column**, which a board card — or lane — drag proposes into to delete it
|
|
/// (04-interactions.md ▸ The trash, settled 2026-07-28, lanes extended 2026-07-29). The index
|
|
/// is always the topmost row.
|
|
case trash
|
|
}
|
|
|
|
var boardRoot: BoardRootKey
|
|
var container: Container
|
|
var index: Int
|
|
|
|
/// The lane this proposal names, or `nil` for the strip and the trash — the shape the container
|
|
/// wore before there were three of them, kept because most readers only ask this one question.
|
|
var laneID: ItemID? {
|
|
if case let .lane(id) = container { return id }
|
|
return nil
|
|
}
|
|
|
|
/// Whether this proposal names the trash column, and therefore means *delete*.
|
|
var isTrash: Bool { container == .trash }
|
|
}
|
|
|
|
// MARK: - Dropping on the trash
|
|
|
|
/// **Drop-on-trash deletes** (04-interactions.md ▸ The trash, settled 2026-07-28, lanes extended
|
|
/// 2026-07-29: "Dropping a live card — or lane — on the shown trash deletes it … a lane drag over the
|
|
/// shown trash proposes the delete alongside its strip slots"), as the two pure facts the gesture is
|
|
/// made of (`TrashDropTests`).
|
|
///
|
|
/// Kept out of the drop context so the ruling is checkable without a window, and stated once so the
|
|
/// **hover** and the **release** cannot disagree about what the trash takes — the hazard being a
|
|
/// modifier pressed *after* the proposal stood, which no `dropUpdated` need ever report.
|
|
enum TrashDrop {
|
|
|
|
/// The row the shadow takes, always: **the topmost**.
|
|
///
|
|
/// Not arbitrary, and the stamp is what makes it honest: "every trash arrival stamps `modified`
|
|
/// and the trash sorts newest-first by that stamp" (04-interactions.md ▸ The trash, re-ruled
|
|
/// 2026-07-31), so a fresh delete genuinely lands on top. The drop therefore still lands exactly
|
|
/// where the shadow shows — the one positional promise every other drop in this app makes —
|
|
/// while being the only proposal on the board the *pointer* does not choose.
|
|
static let landingIndex = 0
|
|
|
|
/// Whether the shown trash takes this session — the whole of the gate, and every clause is a
|
|
/// refusal 04 states in its own words:
|
|
///
|
|
/// - **Both kinds are deliverable** (lanes extended 2026-07-29, retiring "a lane drag proposes
|
|
/// only lane slots"): "a lane drag over the shown trash proposes the delete alongside its strip
|
|
/// slots", which is the same sentence the card gesture has always had one level down. A session
|
|
/// in flight is a session of one of the two kinds, so the clause is `kind != nil` — there is no
|
|
/// third kind to admit by accident, and a file session never arms this object at all.
|
|
/// - **A trash item is already there.** A `.trash` session's vocabulary is restore and copy-out,
|
|
/// at either level; dropping it back where it came from writes nothing.
|
|
/// - **Cross-board is refused.** "No move or paste ever targets the trash": a foreign item
|
|
/// delivered *into* this board's trash would be a transfer-and-delete compound, an operation the
|
|
/// design gives no name and no undo story. It stays where it is.
|
|
/// - **⌥ is refused.** Copying into the trash is not a thing — the copy grammar promises the
|
|
/// original stays exactly where it was, and there is nothing to delete but the original. (A
|
|
/// within-board lane drag never resolves to `.copy` anyway: ⌥ is ignored there,
|
|
/// `DragLocality.operation`. The clause is the card gesture's and covers the lane for free.)
|
|
/// - **Hidden, the trash is invisible to every gesture.** True by construction too (the column is
|
|
/// not rendered, so it has no drop region), and stated here so the claim is testable.
|
|
/// - **The mutating-gesture rule**, like every other write the pointer can start.
|
|
static func accepts(
|
|
kind: DragKind?,
|
|
container: ItemContainer,
|
|
isWithinBoard: Bool,
|
|
operation: TransferOperation,
|
|
isTrashShown: Bool,
|
|
acceptsMutations: Bool
|
|
) -> Bool {
|
|
guard isTrashShown, acceptsMutations else { return false }
|
|
guard kind != nil, container == .board, isWithinBoard else { return false }
|
|
return operation == .move
|
|
}
|
|
}
|
|
|
|
// MARK: - Where an external file drag would land
|
|
|
|
/// Where an external **Finder file** session would land (04-interactions.md ▸ Drag and drop, "Files
|
|
/// from Finder").
|
|
///
|
|
/// A separate type from `DropTarget` because a file session is a separate *mode*: nothing of ours is
|
|
/// in flight, so there is no dragged run to lift out of the resting layout, no operation to resolve
|
|
/// against modifiers, and no source board to compare roots with — only a destination and what the
|
|
/// files would become there.
|
|
struct FileDropTarget: Equatable, Sendable {
|
|
|
|
/// The two landings 04 gives a file drop, and the whole of its behavioural split.
|
|
enum Landing: Equatable, Sendable {
|
|
/// **Onto a card**: the files copy into that card's `attachments/`. A card under the cursor
|
|
/// always wins over the lane behind it — attach beats create, anywhere on the card's bounds.
|
|
/// This is the one file landing that **highlights**, and only because it has no shadow to
|
|
/// show instead (04-interactions.md ▸ Drag and drop, settled 2026-07-28).
|
|
case attach(cardID: ItemID)
|
|
/// **Onto the lane's card grid**: one card per file, landing at this position in the lane's
|
|
/// logical card order.
|
|
///
|
|
/// **Positional, everywhere the grid reaches** (04-interactions.md ▸ Drag and drop, settled
|
|
/// 2026-07-28): "created cards land at the drop position — resolved through the same
|
|
/// card-grid zones an ordinary card drag uses, shadow included", and a release on the lane
|
|
/// **header** resolves to index 0, the topmost position. Append-at-bottom is the creation
|
|
/// trio's rule (⌘N, Return, a double click), never the drop's. `FileDropZones.landing` is
|
|
/// where the index comes from.
|
|
case create(laneID: ItemID, index: Int)
|
|
}
|
|
|
|
/// The board under the cursor — only that board's delegates may commit, and only its lanes draw
|
|
/// the shadows, exactly as `DropTarget.boardRoot` works for our own sessions.
|
|
var boardRoot: BoardRootKey
|
|
|
|
var landing: Landing
|
|
|
|
/// How many files ride along — **one nominal-height shadow per incoming file** on the create path
|
|
/// (04-interactions.md ▸ Drag and drop, settled 2026-07-28: the multi-drag precedent), one shadow
|
|
/// per card that will land. Read off the session's item providers at hover time
|
|
/// (`FinderDrop.shadowCount`), **floored at one** for the drag whose count macOS withholds — the
|
|
/// commit is unaffected either way, since the write counts resolved URLs, not providers.
|
|
///
|
|
/// **Folders are not counted** (same bullet: "a mixed drag proposes for its files only"):
|
|
/// `FinderDrop.importableCount` reads the providers' declared types, so a mixed drag draws
|
|
/// shadows for its files alone and a folders-only drag never proposes at all.
|
|
var fileCount: Int
|
|
}
|
|
|
|
// MARK: - The committed-overlay hold
|
|
|
|
/// The hand-off condition for **the committed-overlay hold** (DRAG-REORDER.md § The
|
|
/// committed-overlay hold), as a value so the state machine is testable without a filesystem.
|
|
///
|
|
/// 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
|
|
/// drag state at release would snap every sibling back to the pre-drop layout for a frame. So the
|
|
/// session flips from *proposing* to *committed*, keeps rendering the arrangement it was showing,
|
|
/// and stands until the destination store applies its next snapshot — **any** snapshot: the
|
|
/// app-mediated echo is normally next, and a foreign one that lands first re-grounds everything
|
|
/// anyway.
|
|
///
|
|
/// It watches `BoardStore.snapshotGeneration`, which since 2026-07-31 does not move for a reload whose
|
|
/// tree came back value-equal, and that is the right counter rather than a hazard: a hold is only ever
|
|
/// armed by a drop whose write actually rearranged something — every drop path refuses a no-op
|
|
/// arrangement *before* it opens a write bracket (`BoardStore.moveCards`, `moveLanes`, `moveLane`) —
|
|
/// so a drop that would land value-equal never writes and never reloads, and its hold is the
|
|
/// watchdog's to retire exactly as it was before the skip existed.
|
|
///
|
|
/// The `timeout` is the same guarantee the drag session's watchdog gives the drag itself: a write
|
|
/// that was refused outright (a read-only board) produces no reload at all, and an overlay with no
|
|
/// hand-off coming must still dissolve and let the snapshot be the authority again.
|
|
struct CommittedHold: Equatable, Sendable {
|
|
|
|
/// The board whose next applied snapshot retires this hold.
|
|
var boardRoot: BoardRootKey
|
|
|
|
/// That board's `snapshotGeneration` at the moment of the commit.
|
|
var generation: Int
|
|
|
|
/// How long the hold may stand with no snapshot arriving. Comfortably longer than a write plus
|
|
/// a watcher round trip, short enough that a refused write does not leave the board drawing an
|
|
/// arrangement it never got.
|
|
static let timeout: Duration = .milliseconds(1500)
|
|
|
|
/// Whether a snapshot applied on `root` at `generation` retires this hold. A snapshot on another
|
|
/// board says nothing about this one, and the *same* generation is the one already on screen at
|
|
/// the commit.
|
|
func isRetired(byRoot root: BoardRootKey, generation: Int) -> Bool {
|
|
root == boardRoot && generation > self.generation
|
|
}
|
|
}
|
|
|
|
// MARK: - Locality
|
|
|
|
/// **Locality picks the default — the Finder volume model** (04-interactions.md ▸ Drag and drop,
|
|
/// settled), as pure functions (`DragLocalityTests`).
|
|
///
|
|
/// Within a board a drag is a **move**: rearranging. Between boards it is a **copy**: transferring,
|
|
/// with the system copy badge showing over the foreign board. **⌥ always forces copy** and **⌘
|
|
/// always forces move** — Finder's exact modifier grammar — and each is a no-op where its behavior
|
|
/// is already the default. The operation is therefore a function of (source board, board under the
|
|
/// cursor, modifiers) sampled every frame, not a decision taken at pickup, which is what lets the
|
|
/// badge track live as the cursor crosses a boundary.
|
|
///
|
|
/// Which board a root names is `BoardRootKey`'s question, and it is asked as an `==`: the two sides
|
|
/// of every comparison below are keys their own stores minted once (`BoardStore.rootKey`), never
|
|
/// paths re-resolved under the cursor.
|
|
enum DragLocality {
|
|
|
|
/// The effective operation, live.
|
|
///
|
|
/// Two carve-outs, both 04-interactions.md's:
|
|
///
|
|
/// - **Lane drags never copy within their board.** ⌥ is simply ignored there: the drag stays a
|
|
/// clean reorder and the badge never shows copy. The within-board lane duplicate exists, but
|
|
/// its home is the clipboard (▸ Clipboard, Lane paste).
|
|
/// - **A trash card's drag is the restore** (▸ The trash). Within its own board it is "an
|
|
/// ordinary move to the drop position"; across boards the default is the copy that leaves the
|
|
/// original in the source trash, and "⌘-drag forces the true cross-board restore-move". Both
|
|
/// fall out of the ordinary locality rule with no trash clause at all, which is the pivot's
|
|
/// whole point.
|
|
static func operation(
|
|
kind: DragKind,
|
|
container: ItemContainer,
|
|
isWithinBoard: Bool,
|
|
modifiers: NSEvent.ModifierFlags
|
|
) -> TransferOperation {
|
|
let forcesCopy = modifiers.contains(.option)
|
|
let forcesMove = modifiers.contains(.command)
|
|
|
|
// The lane carve-out comes first, because it is an outright refusal of the modifier rather
|
|
// than a different default: a within-board lane drag is a reorder and nothing else.
|
|
if kind == .lanes, isWithinBoard { return .move }
|
|
|
|
if forcesMove { return .move }
|
|
if forcesCopy { return .copy }
|
|
_ = container // the container changes which commit runs, never which operation the badge shows
|
|
return isWithinBoard ? .move : .copy
|
|
}
|
|
}
|
|
|
|
// MARK: - DragSession
|
|
|
|
/// The app's one drag session — what is in flight, where it would land, and what a release means
|
|
/// (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
|
///
|
|
/// **App-wide, because a drag crosses windows.** The identities are UUIDs and the folders are paths,
|
|
/// so the source board hides the dragged items while *any* open board's drop delegates propose a
|
|
/// landing spot for them. It is owned by `AppModel` and reached through the environment; the system
|
|
/// `NSItemProvider` payload is the formal drop data (`DragPayload`), and this is what every hover
|
|
/// actually reads — decoding a provider is asynchronous, and `dropUpdated` must answer synchronously
|
|
/// to drive the reflow.
|
|
///
|
|
/// ### What is frozen and what is not
|
|
///
|
|
/// 03-board-ui.md § Motion's animation-proof rule, made structural: the **only** inputs frozen at
|
|
/// drag start are the *dragged items'* own sizes — card heights and lane unit counts, which the
|
|
/// pickup transition corrupts the instant it starts. Everything else (the resting zones, the
|
|
/// destination's standard width, which lanes are live) is recomputed from the current snapshot on
|
|
/// every sample, which is rule 1 of the mid-drag re-grounding trio: a foreign reload just moves the
|
|
/// zones.
|
|
///
|
|
/// ### Termination is structural
|
|
///
|
|
/// `begin` arms a watchdog that polls the physical mouse-button state and clears the session shortly
|
|
/// after the button is released, no matter where the drop landed — on a delegate (whose
|
|
/// `performDrop` already cleaned up, making the watchdog a no-op), on empty window space, in another
|
|
/// window, outside every window, or on a cancelled drag. This is the pathfinder's hard-won lifecycle
|
|
/// pair: cleanup on session-phase events must be gated on the button being physically up (a finished
|
|
/// session's events can arrive *after* the next drag has started), and the watchdog is the
|
|
/// guaranteed path for the sessions SwiftUI never reports at all.
|
|
@MainActor
|
|
@Observable
|
|
final class DragSession {
|
|
|
|
// MARK: What is in flight
|
|
|
|
/// Which level is being dragged; `nil` when no session is in flight — which is what "no drag"
|
|
/// means here rather than a separate flag.
|
|
private(set) var kind: DragKind?
|
|
|
|
/// The container the drag started in. A trash card's drag is a `.cards` session in `.trash`,
|
|
/// and that is the whole of what makes it a restore (04-interactions.md ▸ The trash).
|
|
private(set) var container: ItemContainer = .board
|
|
|
|
/// **Whether the selection this drag was picked up from spanned both kinds** — only ever true in
|
|
/// the trash, whose selection went kind-blind (04-interactions.md ▸ The trash, ruled 2026-07-31).
|
|
///
|
|
/// A session carries items of exactly one `kind` — the pasteboard type is per-kind and a
|
|
/// `DragPayload` names one — so a mixed selection dragged from a trash row starts a session over
|
|
/// *its* kind and silently leaves the other behind. That silence is what this flag exists to
|
|
/// prevent: "pickup is allowed — the selection is legal — but every out-of-trash drop target
|
|
/// refuses the mixed payload, and the release surfaces a notice explaining the rule". The refusal
|
|
/// is at the commit (`BoardDropContext.commitDrop`), where every drop out of the trash funnels,
|
|
/// rather than at hover — 04 puts the explanation at the release.
|
|
private(set) var mixesKinds = false
|
|
|
|
/// The dragged items in **flatten order** — the order they will land in.
|
|
private(set) var members: [ItemID] = []
|
|
|
|
/// Fast identity test for hiding the originals wherever they render.
|
|
private(set) var memberSet: Set<ItemID> = []
|
|
|
|
/// The dragged items' folders, aligned 1:1 with `members` — what the cross-board commits take.
|
|
@ObservationIgnored private(set) var folders: [URL] = []
|
|
|
|
/// The board the drag started in. Root and store are kept separately because the store may go
|
|
/// away with its window mid-drag while the root — the left-hand side of the locality
|
|
/// comparison — stays perfectly usable.
|
|
private(set) var sourceRoot: BoardRootKey?
|
|
|
|
@ObservationIgnored private(set) weak var sourceStore: BoardStore?
|
|
|
|
/// The dragged cards' heights, **frozen at drag start**. `DropSlotMath.cardSlot` caps the
|
|
/// vertical trigger region at the first one (the shadow the cursor is over), and the shadows are
|
|
/// drawn at all of them.
|
|
@ObservationIgnored private(set) var cardHeights: [CGFloat] = []
|
|
|
|
/// The dragged lanes' width units, frozen at drag start — the run's span, measured against
|
|
/// whichever board's standard width it is being proposed into.
|
|
@ObservationIgnored private(set) var laneUnits: [Int] = []
|
|
|
|
/// The per-lane resting layouts this drag's retargets propose against, built once per snapshot
|
|
/// rather than once per mouse sample (`RestingLayoutCache`, which states why that leaves rule 1
|
|
/// of the re-grounding trio exactly as it was).
|
|
///
|
|
/// `@ObservationIgnored` for the reason `LaneDropRegistry` is not `@Observable` at all: it is
|
|
/// written from *event* handlers, and a cache fill that invalidated the strip would be the
|
|
/// animation feedback loop this whole model exists to avoid (03-board-ui.md § Motion).
|
|
///
|
|
/// **Session-scoped**, so it cannot outlive the drag it was filled for — and so a layout can
|
|
/// assume the one input that is neither in its key nor read live, `cardHeights`, is the frozen
|
|
/// set this session picked up with. `begin` and `end` bracket every session this object has, and
|
|
/// both clear it.
|
|
@ObservationIgnored let restingLayouts = RestingLayoutCache()
|
|
|
|
// MARK: Where it would land
|
|
|
|
/// The current proposal, or `nil` when the drag has none — a fresh session before the first
|
|
/// sample, or one whose target lane vanished in a reload (rule 2 of the re-grounding
|
|
/// trio). **Release with no valid proposal cancels.**
|
|
private(set) var proposal: DropTarget?
|
|
|
|
/// The effective operation, re-resolved on every `dropUpdated` so the system badge tracks live.
|
|
private(set) var operation: TransferOperation = .move
|
|
|
|
/// The committed-overlay hold, or `nil` while the session is still proposing. See
|
|
/// `CommittedHold`.
|
|
private(set) var hold: CommittedHold?
|
|
|
|
// MARK: The external file mode
|
|
|
|
/// Where an external Finder file drag would land, or `nil` when there is none in flight or it is
|
|
/// over nothing that accepts it (`FileDropTarget`).
|
|
///
|
|
/// **A distinct mode, deliberately kept out of everything above.** A file session arms none of
|
|
/// this object's own state — `kind` stays `nil`, so `isActive` stays false, no member is hidden
|
|
/// from any resting layout, and the marquee and card/lane machinery carry on as if no drag
|
|
/// existed. It lives here rather than in a drop delegate for the reason the rest does: the
|
|
/// highlight and the shadows render off it, so it has to be observable and it has to be one
|
|
/// value the whole window agrees on.
|
|
private(set) var fileTarget: FileDropTarget?
|
|
|
|
@ObservationIgnored private var watchdog: Task<Void, Never>?
|
|
@ObservationIgnored private var fileWatchdog: Task<Void, Never>?
|
|
@ObservationIgnored private var holdTimeoutTask: Task<Void, Never>?
|
|
|
|
/// How long this session's holds may stand with no snapshot arriving — `CommittedHold.timeout`,
|
|
/// and a `var` for one reason only: the discard path is a `Task` sleeping on the main actor, and
|
|
/// a test that had to wait the real figure out would be a 1.5 s wall clock in the suite
|
|
/// (`DragSessionTests`). Nothing in the app writes it.
|
|
@ObservationIgnored var holdTimeout: Duration = CommittedHold.timeout
|
|
|
|
init() {}
|
|
|
|
// MARK: Queries
|
|
|
|
var isActive: Bool { kind != nil }
|
|
var isDraggingCards: Bool { kind == .cards }
|
|
var isDraggingLanes: Bool { kind == .lanes }
|
|
|
|
/// Whether the release has **settled**: the write is issued and the arrangement the session was
|
|
/// showing is being held as overlay state until the echo reload lands (`CommittedHold`).
|
|
var isSettled: Bool { hold != nil }
|
|
|
|
/// N — the number of contiguous shadows the proposal draws.
|
|
var shadowCount: Int { members.count }
|
|
|
|
/// Whether `id` is one of the dragged items.
|
|
func isDragging(_ id: ItemID) -> Bool { memberSet.contains(id) }
|
|
|
|
/// The items to **leave out of the resting layout** on the board rooted at `root`.
|
|
///
|
|
/// Only the source board hides anything, and only for a board-side session: a trash card's drag
|
|
/// carries items that render in the trash column, not in any lane's masonry, so no lane loses a
|
|
/// card to it. Those rows dim in place instead (`CardFaceView`, `TrashLaneRowView`), which is
|
|
/// the same treatment a re-admitted board original now wears — one vocabulary for "this is the
|
|
/// source of the drag", wherever the source stays visible.
|
|
///
|
|
/// **A move lifts the run out; a copy leaves it in** (04-interactions.md ▸ Drag and drop:
|
|
/// "originals stay", for the within-board ⌥-drag and for the cross-board default alike — ruled
|
|
/// 2026-08-01, retiring the operation-blind carve-out this used to be). The layout on screen is
|
|
/// therefore always the arrangement the release would produce: a copy's source board shows what
|
|
/// it will still hold, a move's shows what it will have given away. Flipping ⌥ or ⌘ mid-drag
|
|
/// reflows the source board once, and that one-shot reflow *is* the feedback the modifier is
|
|
/// asking for — the previous rule's fear of "flapping" traded the answer to "what will this
|
|
/// drop leave behind?" for a stillness nobody asked for.
|
|
///
|
|
/// Reading `operation` here is what makes the flip visible at all: the property is observed, so
|
|
/// every surface that builds a resting layout off this method re-renders when it changes. The
|
|
/// flip lands on the next `dropUpdated`, since that is where the operation is re-resolved; a
|
|
/// modifier pressed with the mouse perfectly still waits for the next motion (Backlog ▸
|
|
/// Stationary modifier flips).
|
|
///
|
|
/// **The hold freezes the answer**, because `resolveOperation` refuses to move once a release
|
|
/// has settled: a settled copy keeps its originals on screen and a settled move keeps them
|
|
/// lifted, in both cases until the echo snapshot lands and the real faces take over.
|
|
func hiddenMembers(onBoardRooted root: BoardRootKey) -> Set<ItemID> {
|
|
guard isActive, container == .board, let sourceRoot, root == sourceRoot
|
|
else { return [] }
|
|
return operation == .copy ? [] : memberSet
|
|
}
|
|
|
|
/// The proposal's index when it names this board's strip, else `nil` — the lane strip's shadow
|
|
/// run position.
|
|
func stripProposal(onBoardRooted root: BoardRootKey) -> Int? {
|
|
guard kind == .lanes, let proposal, proposal.container == .strip,
|
|
proposal.boardRoot == root
|
|
else { return nil }
|
|
return proposal.index
|
|
}
|
|
|
|
/// The proposal's index when it names `laneID` on this board, else `nil` — the masonry's shadow
|
|
/// run position, in the lane's logical card order.
|
|
func laneProposal(onBoardRooted root: BoardRootKey, laneID: ItemID) -> Int? {
|
|
guard kind == .cards, let proposal, proposal.container == .lane(laneID),
|
|
proposal.boardRoot == root
|
|
else { return nil }
|
|
return proposal.index
|
|
}
|
|
|
|
/// The proposal's index when it names this board's **trash column**, else `nil` — the delete
|
|
/// gesture's shadow row (04-interactions.md ▸ The trash).
|
|
///
|
|
/// Always `TrashDrop.landingIndex`, and read through this accessor anyway so the column asks the
|
|
/// same "is it me?" question every other container asks, and gets the position from the same
|
|
/// place.
|
|
///
|
|
/// **Either kind proposes here** (lanes extended 2026-07-29), so there is no kind clause: what
|
|
/// makes the proposal legal is `TrashDrop.accepts`, asked at hover and again at release, and a
|
|
/// second copy of its answer written here could only ever disagree with it.
|
|
func trashProposal(onBoardRooted root: BoardRootKey) -> Int? {
|
|
guard isActive, let proposal, proposal.container == .trash,
|
|
proposal.boardRoot == root
|
|
else { return nil }
|
|
return proposal.index
|
|
}
|
|
|
|
/// The members that are still there — **rule 3 of the re-grounding trio**: drag membership is a
|
|
/// UUID set that vanished items leave silently (`TransientBoardState.dragMembers`), and when the
|
|
/// last one goes the drag has emptied itself. Partial vanishing drops the survivors, matching
|
|
/// the pending-cut precedent.
|
|
///
|
|
/// The source store answers because it is the one re-grounding the set against each reload; with
|
|
/// its window gone there is nothing left to invalidate the set, and the folders on disk are as
|
|
/// good as they were.
|
|
var survivors: [Int] {
|
|
guard let store = sourceStore else { return Array(members.indices) }
|
|
let live = store.transient.dragMembers.ids
|
|
return members.indices.filter { live.contains(members[$0]) }
|
|
}
|
|
|
|
// MARK: The file mode's queries and lifecycle
|
|
|
|
/// The card an external file drag is hovering **on this board**, or `nil` — the attach
|
|
/// highlight's one input (`CardFaceView`).
|
|
func fileAttachTarget(onBoardRooted root: BoardRootKey) -> ItemID? {
|
|
guard let fileTarget,
|
|
fileTarget.boardRoot == root,
|
|
case let .attach(cardID) = fileTarget.landing
|
|
else { return nil }
|
|
return cardID
|
|
}
|
|
|
|
/// The shadow run a file drop would open in `laneID` on this board — where the created cards
|
|
/// land and how many there are — or `nil` when the proposal is elsewhere.
|
|
///
|
|
/// **This run is the create path's whole feedback** (04-interactions.md ▸ Drag and drop, settled
|
|
/// 2026-07-28): there is no lane-level highlight to go with it, deliberately — each target gets
|
|
/// one clear signal, and `fileAttachTarget` above is the highlight precisely because a card
|
|
/// target has no shadow.
|
|
///
|
|
/// The floor restates `FinderDrop.shadowCount`'s, at the render end rather than in place of it: a
|
|
/// proposal that somehow carried a zero would otherwise open a run of no shadows at all, which is
|
|
/// a landing spot the user cannot see.
|
|
func fileLaneProposal(onBoardRooted root: BoardRootKey, laneID: ItemID) -> (index: Int, count: Int)? {
|
|
guard let fileTarget,
|
|
fileTarget.boardRoot == root,
|
|
case let .create(lane, index) = fileTarget.landing,
|
|
lane == laneID
|
|
else { return nil }
|
|
return (index: index, count: max(1, fileTarget.fileCount))
|
|
}
|
|
|
|
/// Records where the files would land. `nil` withdraws the proposal — over a gap, the outer
|
|
/// margin, the trash column, or a board that refuses the drop outright.
|
|
///
|
|
/// **No hysteresis, unlike our own sessions.** A card or lane proposal deliberately *holds* while
|
|
/// the cursor crosses ambiguous territory, because the drop must land where the shadows show even
|
|
/// if the cursor drifted off a live zone. A file drop has no such contract: it is a plain "what is
|
|
/// under the cursor right now", so leaving every target simply clears the highlight and the drop
|
|
/// is refused (the pathfinder's `retargetFile` precedent).
|
|
func proposeFile(_ target: FileDropTarget?) {
|
|
guard fileTarget != target else { return }
|
|
let wasHovering = fileTarget != nil
|
|
fileTarget = target
|
|
if target == nil {
|
|
fileWatchdog?.cancel()
|
|
fileWatchdog = nil
|
|
} else if !wasHovering {
|
|
armFileWatchdog()
|
|
}
|
|
}
|
|
|
|
// MARK: Lifecycle
|
|
|
|
/// Begins a card session — board faces or trash cards.
|
|
///
|
|
/// - Parameters:
|
|
/// - members: the dragged cards in flatten order (`SelectionGrammar.boardCards`, or the
|
|
/// trash's own order for a trash-card drag).
|
|
/// - heights: their measured heights, captured **before** the pickup transition starts.
|
|
func beginCards(
|
|
_ members: [ItemID],
|
|
folders: [URL],
|
|
heights: [CGFloat],
|
|
container: ItemContainer,
|
|
source: BoardStore,
|
|
mixesKinds: Bool = false
|
|
) {
|
|
begin(
|
|
kind: .cards, members: members, folders: folders,
|
|
container: container, source: source, mixesKinds: mixesKinds
|
|
)
|
|
cardHeights = heights
|
|
laneUnits = []
|
|
}
|
|
|
|
/// Begins a lane session.
|
|
///
|
|
/// `container` is a parameter because a **trashed lane row**'s drag is a `.lanes` session in
|
|
/// `.trash` — the restore at the lane level (04-interactions.md ▸ The trash ▸ Drag-to-restore).
|
|
func beginLanes(
|
|
_ members: [ItemID],
|
|
folders: [URL],
|
|
units: [Int],
|
|
container: ItemContainer = .board,
|
|
source: BoardStore,
|
|
mixesKinds: Bool = false
|
|
) {
|
|
begin(
|
|
kind: .lanes, members: members, folders: folders,
|
|
container: container, source: source, mixesKinds: mixesKinds
|
|
)
|
|
laneUnits = units
|
|
cardHeights = []
|
|
}
|
|
|
|
private func begin(
|
|
kind: DragKind,
|
|
members: [ItemID],
|
|
folders: [URL],
|
|
container: ItemContainer,
|
|
source: BoardStore,
|
|
mixesKinds: Bool
|
|
) {
|
|
endHold()
|
|
restingLayouts.clear()
|
|
self.kind = kind
|
|
self.members = members
|
|
self.memberSet = Set(members)
|
|
self.folders = folders
|
|
self.container = container
|
|
self.mixesKinds = mixesKinds
|
|
self.sourceStore = source
|
|
self.sourceRoot = source.rootKey
|
|
self.proposal = nil
|
|
self.operation = .move
|
|
// The reload-resolved drag set: vanished members leave it silently, which is what
|
|
// `survivors` reads and what "an emptied drag cancels itself" is stated in terms of.
|
|
source.transient.dragMembers = ItemReferenceSet(ids: memberSet, container: container)
|
|
armWatchdog()
|
|
}
|
|
|
|
/// Records a new proposal. `nil` withdraws it — rule 2's "the shadow withdraws".
|
|
///
|
|
/// **A settled release is past retargeting**: the write naming that slot is already on its way,
|
|
/// so a late callback arriving after the commit — a stray `dropUpdated`, a revalidation — must
|
|
/// not move or withdraw the arrangement the hold is drawing.
|
|
func propose(_ target: DropTarget?) {
|
|
guard isActive, hold == nil, proposal != target else { return }
|
|
proposal = target
|
|
}
|
|
|
|
/// Re-resolves the effective operation against the board under the cursor and the modifiers
|
|
/// **right now**, and hands it back for the `DropProposal` the badge tracks.
|
|
///
|
|
/// **A settled release freezes it**, `propose`'s guard for a reason that is now the same one:
|
|
/// the operation decides what the source board's resting layout holds (`hiddenMembers`) as well
|
|
/// as which write went out, so a stray `dropUpdated` arriving after the commit — with the user's
|
|
/// finger already off ⌥ — would re-lift originals the write is about to leave in place. The
|
|
/// write named an operation; the overlay draws that operation until the echo lands.
|
|
///
|
|
/// `modifiers` defaults to the live flags, which is the only thing the app ever passes; it is a
|
|
/// parameter so the resolution is checkable without a keyboard, exactly as `DragLocality`'s own
|
|
/// pure function is.
|
|
@discardableResult
|
|
func resolveOperation(
|
|
destinationRoot: BoardRootKey,
|
|
modifiers: NSEvent.ModifierFlags = NSEvent.modifierFlags
|
|
) -> TransferOperation {
|
|
guard let kind, let sourceRoot, hold == nil else { return operation }
|
|
let resolved = DragLocality.operation(
|
|
kind: kind,
|
|
container: container,
|
|
isWithinBoard: sourceRoot == destinationRoot,
|
|
modifiers: modifiers
|
|
)
|
|
if resolved != operation { operation = resolved }
|
|
return resolved
|
|
}
|
|
|
|
/// Ends the session outright — a cancel, a release with no valid proposal, or the watchdog.
|
|
func end() {
|
|
sourceStore?.transient.dragMembers = .empty
|
|
kind = nil
|
|
members = []
|
|
memberSet = []
|
|
folders = []
|
|
cardHeights = []
|
|
laneUnits = []
|
|
restingLayouts.clear()
|
|
proposal = nil
|
|
operation = .move
|
|
sourceStore = nil
|
|
sourceRoot = nil
|
|
watchdog?.cancel()
|
|
watchdog = nil
|
|
endHold()
|
|
}
|
|
|
|
/// Enters the committed phase: the arrangement the session was showing stays on screen until
|
|
/// `store` applies its next snapshot (`CommittedHold`).
|
|
///
|
|
/// Everything that drives the rendering — the members, the proposal, the source root, the
|
|
/// operation — is kept exactly as it was, so "keeps rendering the arrangement it was showing"
|
|
/// needs no second mechanism: the shadows stay at the landing slots and the originals stay
|
|
/// exactly where the operation put them — lifted out for a move, standing dimmed in place for a
|
|
/// copy — until the snapshot carrying the write arrives and the real faces take their place.
|
|
/// Freezing the operation is `resolveOperation`'s own guard; the hold this method arms is what
|
|
/// that guard reads.
|
|
func commit(into store: BoardStore) {
|
|
guard isActive else { return }
|
|
sourceStore?.transient.dragMembers = .empty
|
|
watchdog?.cancel()
|
|
watchdog = nil
|
|
let hold = CommittedHold(boardRoot: store.rootKey, generation: store.snapshotGeneration)
|
|
self.hold = hold
|
|
let timeout = holdTimeout
|
|
holdTimeoutTask?.cancel()
|
|
holdTimeoutTask = Task { @MainActor [weak self] in
|
|
try? await Task.sleep(for: timeout)
|
|
guard !Task.isCancelled, let self else { return }
|
|
self.expire(hold)
|
|
}
|
|
}
|
|
|
|
/// **The failed write's path**: no echo is coming, so the hold discards and the board animates
|
|
/// back to snapshot order (03-board-ui.md § Motion — "the width-drag rollback posture: the
|
|
/// action visibly doesn't happen"). The one path in the settle that *is* motion, and the reason
|
|
/// it wears `Motion.dragReflow`: what moves is the arrangement un-happening.
|
|
///
|
|
/// The timeout's own body, spelled as a method rather than inlined in the `Task`, so the discard
|
|
/// can be pinned directly as well as through the clock (`DragSessionTests`). A hold that has
|
|
/// already been handed off — or replaced by a second drag's — is not this one's to end.
|
|
func expire(_ hold: CommittedHold) {
|
|
guard self.hold == hold else { return }
|
|
withAnimation(Motion.dragReflow(reduced: Motion.prefersReducedMotion)) { end() }
|
|
}
|
|
|
|
/// The hand-off: a snapshot landed on `root`, so an overlay standing in for it dissolves.
|
|
///
|
|
/// Called from every board window's own snapshot-generation watch, which is why the hold names
|
|
/// the board it belongs to — a reload on some other board says nothing about this one.
|
|
func handOff(root: BoardRootKey, generation: Int) {
|
|
guard let hold, hold.isRetired(byRoot: root, generation: generation) else { return }
|
|
end()
|
|
}
|
|
|
|
private func endHold() {
|
|
hold = nil
|
|
holdTimeoutTask?.cancel()
|
|
holdTimeoutTask = nil
|
|
}
|
|
|
|
/// Cleanup for a session-phase event SwiftUI reports.
|
|
///
|
|
/// **Gated on the physical button being up**, because a finished session's `.ended` /
|
|
/// `.dataTransferCompleted` events can be delivered *after the user has already started the next
|
|
/// drag*, and a naive handler wipes the new session's state — no shadow, drop dead. The watchdog
|
|
/// covers every genuinely-ended session anyway, so refusing here costs nothing.
|
|
func endIfButtonReleased() {
|
|
guard NSEvent.pressedMouseButtons == 0 else { return }
|
|
guard isActive, hold == nil else { return }
|
|
withAnimation(Motion.dragReflow(reduced: Motion.prefersReducedMotion)) { end() }
|
|
}
|
|
|
|
// MARK: The watchdog — the single guaranteed termination path
|
|
|
|
/// Polls the physical mouse-button state while a drag is in flight. When the button is released
|
|
/// and, after a short grace period (long enough for a landing `performDrop` to run first), a
|
|
/// session still lingers, it is cleared here. Backstops every path a drop delegate cannot see.
|
|
private func armWatchdog() {
|
|
watchdog?.cancel()
|
|
watchdog = Task { @MainActor [weak self] in
|
|
while !Task.isCancelled {
|
|
try? await Task.sleep(for: .milliseconds(120))
|
|
guard let self, self.isActive, self.hold == nil else { return }
|
|
guard NSEvent.pressedMouseButtons == 0 else { continue }
|
|
try? await Task.sleep(for: .milliseconds(250))
|
|
guard !Task.isCancelled, self.isActive, self.hold == nil else { return }
|
|
withAnimation(Motion.dragReflow(reduced: Motion.prefersReducedMotion)) { self.end() }
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The file mode's own watchdog, and its only guaranteed termination path.
|
|
///
|
|
/// An external session is not ours to end: no `performDrop` runs when the user drops the files
|
|
/// somewhere else entirely, and `onDragSessionUpdated` reports only sessions this app started. A
|
|
/// drag is a button held down, so the same poll the internal watchdog uses answers here — when
|
|
/// the button has been up for a grace period and a target is still standing, the highlight is
|
|
/// stale and goes.
|
|
private func armFileWatchdog() {
|
|
fileWatchdog?.cancel()
|
|
fileWatchdog = Task { @MainActor [weak self] in
|
|
while !Task.isCancelled {
|
|
try? await Task.sleep(for: .milliseconds(120))
|
|
guard let self, self.fileTarget != nil else { return }
|
|
guard NSEvent.pressedMouseButtons == 0 else { continue }
|
|
try? await Task.sleep(for: .milliseconds(250))
|
|
guard !Task.isCancelled, self.fileTarget != nil else { return }
|
|
withAnimation(Motion.dragReflow(reduced: Motion.prefersReducedMotion)) {
|
|
self.proposeFile(nil)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|