A lane folds to a fixed slim vertical strip carrying its glyph, its card-count badge and its title turned on its side, and the strip is deliberately not part of the window's division: the expanded lanes' units divide what is left once each folded strip's fixed width has come off the top, so folding a lane is a re-divide trigger of the Show/Hide Trash family — the window never moves and the siblings grow into what the lane gave up. The state is a first-class lane frontmatter key, `collapsed: true`, and document state exactly as `width` is: the files are the board, so an agent folds a lane by writing one key. Absent means expanded, expanding removes the key rather than writing `false` (the remove-at-default family beside a one-unit `width`, the empty rename's `title` and the None well's `background`), and the lane's `width` rides along untouched so expanding restores the lane the user had. The read is `width`'s leniency one type over — a boolean scalar or a quoted boolean word reads as itself, everything else has no reading at all and renders as expanded, bytes preserved either way. Toggling is the header's always-visible collapse chevron, the lane context menu's single Collapse Lane / Expand Lane row, and a plain click anywhere on the strip; a modified click on the strip stays the ordinary selection grammar, so a folded lane is still selectable by pointer. The title reads bottom-up and is justified to the top of the room below the strip's chrome (owner ruling 2026-08-08), truncating against the strip's own height. While folded the lane draws no cards at all, which is what makes every exclusion true by construction rather than by a guard per gesture: no card face means no marquee target and no navigation frame, and no registered grid means the masonry's drop zones have nothing to resolve against. What did need code is the half that names absolute destinations — the option-arrow jumps and the arrow seed scan past a folded lane, the lane domain's down-arrow is inert on one, and New Card skips it (a selection inside one falls through to the last-active lane, the stale selection's rule). A drop on the strip appends at the lane's end, cards and Finder files alike, with an accent edge standing in for the shadow the strip has no masonry to open; there is no hover-to-auto- expand yet. Lane reorder works on the strip, and a dragged folded lane carries its fold, so its shadow and its replica are the strip rather than its units. The write is `writeLaneWidths` clause for clause — one `updateIndex` bracket, the same stamp behaviour, the same three do-nothing paths — with two new `WriteOperation` cases and two new undo verbs rather than one of each, because a banner or an Edit-menu row that said "resize" after Collapse Lane would name a control the user never touched. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
1058 lines
56 KiB
Swift
1058 lines
56 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] = []
|
|
|
|
/// Which of the dragged lanes are **collapsed**, aligned 1:1 with `laneUnits` — frozen at drag
|
|
/// start beside them (03-board-ui.md § Lane ▸ Collapsed lanes).
|
|
///
|
|
/// A second array rather than a richer element type, because it is read in exactly the two places
|
|
/// `laneUnits` is and always together with it: the shadow's width, and the run's span cap
|
|
/// (`BoardView.stripSlots`, `DropSlotMath.laneSlot`). A folded lane's shadow is its slim strip, not
|
|
/// the slot its preserved `width` would have bought — the drop leaves it folded, so a wider shadow
|
|
/// would promise a lane the strip is not about to draw.
|
|
///
|
|
/// **Empty is "none of them"**, which is what a card session and a cleared session both hold, and
|
|
/// what a `beginLanes` caller with no collapse question to ask leaves it as.
|
|
@ObservationIgnored private(set) var laneCollapsed: [Bool] = []
|
|
|
|
/// 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 modifier flip
|
|
|
|
/// **The stationary-flip nudge**: bumped once for every change to ⌥/⌘ while a session is in
|
|
/// flight and unsettled (`ModifierFlipSource`, armed at `begin` and stopped at `end`).
|
|
///
|
|
/// The whole of what this object can say about a flip, and deliberately so. The *effect* of a
|
|
/// flip is a re-proposal, and re-proposing needs a board window's geometry — which board is
|
|
/// under the cursor, where its lanes are drawn — none of which an app-wide session has. So the
|
|
/// flip is published as a counter and the hovered board's own drop context turns it back into
|
|
/// the one shared retarget (`BoardDropContext.retargetAfterModifierFlip`), which is the same
|
|
/// seam the autoscroll driver's every scroll step goes through.
|
|
///
|
|
/// Observed, unlike everything else the event handlers write here: it exists to invalidate a
|
|
/// board window's body. That costs a strip body pass per **keystroke**, not per mouse sample —
|
|
/// and the flip changes `hiddenMembers`, so that pass was happening anyway.
|
|
private(set) var modifierGeneration = 0
|
|
|
|
/// The board surface that last resolved this session's proposal — which window, and which of
|
|
/// its drop surfaces — so a flip can re-run *that* retarget rather than guess at one.
|
|
///
|
|
/// **The window is matched by registry identity, never by board root.** Two windows open on one
|
|
/// board share a store and a root but not a `LaneDropRegistry` (the cache's key already turns on
|
|
/// exactly this), and only one of them has the cursor over it; a root comparison would have the
|
|
/// other one retargeting against a cursor that is nowhere near its lanes.
|
|
///
|
|
/// **Weak**, because a board window can close mid-drag: the session outlives it, and a flip
|
|
/// afterwards simply finds no one to answer — which is the honest answer, since the surface that
|
|
/// was resolving the proposal is gone.
|
|
///
|
|
/// `@ObservationIgnored` for `LaneDropRegistry`'s own reason: it is written from *event*
|
|
/// handlers, on every sample of every drag, and a body that re-ran for it would be the animation
|
|
/// feedback loop the drop model exists to avoid.
|
|
@ObservationIgnored private(set) var retargetOrigin: RetargetOrigin?
|
|
|
|
/// Which board window ran the last retarget, and which of its surfaces.
|
|
struct RetargetOrigin {
|
|
|
|
/// The three surfaces that resolve a proposal — the three retargets a flip can replay.
|
|
/// Each names the function that recorded it: `.strip` is `retargetLanes`, `.lane` is
|
|
/// `retargetCards(inLane:)`, `.trash` is `retargetTrash`.
|
|
enum Surface: Equatable {
|
|
case strip
|
|
case lane(ItemID)
|
|
case trash
|
|
}
|
|
|
|
weak var registry: LaneDropRegistry?
|
|
var surface: Surface
|
|
}
|
|
|
|
// 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>?
|
|
|
|
/// Where the flips come from, and the running watch — armed at `begin`, stopped at `end`, and
|
|
/// nowhere else (see `armFlipWatch`).
|
|
@ObservationIgnored private let flipSource: any ModifierFlipSource
|
|
@ObservationIgnored private var flipWatch: (any ModifierFlipWatch)?
|
|
|
|
/// The ⌥/⌘ state the last flip reported, so a `.flagsChanged` that moved neither — ⇧ for the
|
|
/// marquee's own grammar, ⌃, caps lock, a function key — costs nothing at all.
|
|
@ObservationIgnored private var flipFlags: NSEvent.ModifierFlags = []
|
|
|
|
/// 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
|
|
|
|
/// Fires the alignment tick for a genuine new landing spot (`propose`). A `var` for the reason
|
|
/// `holdTimeout` is one: the real `NSHapticFeedbackManager` call needs a Force Touch trackpad a
|
|
/// test bundle has no way to feed, so a test substitutes a closure that counts firings instead.
|
|
@ObservationIgnored var hapticTick: () -> Void = {
|
|
NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .default)
|
|
}
|
|
|
|
/// **The two flags the effective operation is a function of** — `DragLocality.operation` reads
|
|
/// these and nothing else, so these are the whole of what "a flip" means here.
|
|
static let operationFlags: NSEvent.ModifierFlags = [.option, .command]
|
|
|
|
/// `flipSource` is defaulted to the shipping local monitor; it is a parameter for the reason
|
|
/// `DragAutoScroller`'s tick source is one — the real thing needs a live `NSApplication` event
|
|
/// stream, which a test bundle has no way to feed (`ModifierFlipTests`).
|
|
init(flipSource: any ModifierFlipSource = LocalModifierFlipSource()) {
|
|
self.flipSource = flipSource
|
|
}
|
|
|
|
// 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 —
|
|
/// **or, with the mouse perfectly still, on the flip's own nudge**: drop callbacks arrive only
|
|
/// while the mouse moves, so a `.flagsChanged` monitor bumps `modifierGeneration` and the
|
|
/// hovered board re-runs the retarget the last callback ran
|
|
/// (`BoardDropContext.retargetAfterModifierFlip`). A stationary ⌥ is therefore answered at the
|
|
/// keystroke rather than at the next mouse sample.
|
|
///
|
|
/// **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.
|
|
/// - seed: the run's own resting slot, proposed from the first frame — see `begin`.
|
|
func beginCards(
|
|
_ members: [ItemID],
|
|
folders: [URL],
|
|
heights: [CGFloat],
|
|
container: ItemContainer,
|
|
source: BoardStore,
|
|
mixesKinds: Bool = false,
|
|
seed: DropTarget? = nil
|
|
) {
|
|
begin(
|
|
kind: .cards, members: members, folders: folders,
|
|
container: container, source: source, mixesKinds: mixesKinds, seed: seed
|
|
)
|
|
cardHeights = heights
|
|
laneUnits = []
|
|
laneCollapsed = []
|
|
}
|
|
|
|
/// 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).
|
|
///
|
|
/// `collapsed` is the run's fold state, aligned with `units` — see `laneCollapsed`. It defaults to
|
|
/// empty, which reads as "none of them": a trashed lane row has no `collapsed` reading to carry
|
|
/// (03-board-ui.md § Trash — a trashed lane is an opaque row, never a lane on the board).
|
|
func beginLanes(
|
|
_ members: [ItemID],
|
|
folders: [URL],
|
|
units: [Int],
|
|
collapsed: [Bool] = [],
|
|
container: ItemContainer = .board,
|
|
source: BoardStore,
|
|
mixesKinds: Bool = false,
|
|
seed: DropTarget? = nil
|
|
) {
|
|
begin(
|
|
kind: .lanes, members: members, folders: folders,
|
|
container: container, source: source, mixesKinds: mixesKinds, seed: seed
|
|
)
|
|
laneUnits = units
|
|
laneCollapsed = collapsed
|
|
cardHeights = []
|
|
}
|
|
|
|
/// `seed` is the pickup's own-slot proposal — "at drag start it replaces the item's original
|
|
/// space" (DRAG-REORDER.md § The pieces), made true from the very first frame rather than from
|
|
/// the first `dropUpdated`. Without it the lift-out and the shadow's arrival land in different
|
|
/// transactions: the masonry closes the vacated gap (un-animated — `shadowRun` hasn't moved) and
|
|
/// then springs it back open when the first hover sample proposes the own slot, a shuffle for no
|
|
/// information. Seeded, the lift and the shadow are one transaction with identical geometry —
|
|
/// the run's frozen sizes at the run's own resting position — so nothing on the board moves at
|
|
/// pickup, which is what the own-slot fixed point always promised.
|
|
///
|
|
/// Set directly rather than through `propose`, because a pickup is not a *new landing spot*:
|
|
/// the alignment tick stays reserved for genuine retargets. The first real `dropUpdated`
|
|
/// re-proposes the same slot and `propose`'s early-out makes it a silent no-op.
|
|
private func begin(
|
|
kind: DragKind,
|
|
members: [ItemID],
|
|
folders: [URL],
|
|
container: ItemContainer,
|
|
source: BoardStore,
|
|
mixesKinds: Bool,
|
|
seed: DropTarget?
|
|
) {
|
|
// A new drag's first `sinceLastMs` must not be the gap since the previous drag's last sample.
|
|
#if DEBUG
|
|
DragSignposts.resetInputSampling()
|
|
#endif
|
|
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 = seed
|
|
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)
|
|
self.retargetOrigin = nil
|
|
armWatchdog()
|
|
armFlipWatch()
|
|
}
|
|
|
|
/// 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
|
|
// One tick per genuine new landing spot, never per pixel: the hysteresis/dead-region model
|
|
// above already makes retargets sparse, which is exactly the case Apple gives `.alignment`
|
|
// for rather than the "don't overuse haptics" one a per-sample tick would land in. A
|
|
// withdrawal (`target == nil`) is losing a target, not landing on one, so it stays silent.
|
|
if target != nil {
|
|
hapticTick()
|
|
}
|
|
}
|
|
|
|
/// Records which board window's surface just resolved the proposal (`RetargetOrigin`) — the
|
|
/// address a stationary modifier flip replays its retarget at.
|
|
///
|
|
/// Written by the three retargets themselves rather than by their five callers, so the drop
|
|
/// delegates, the strip's fall-through and the autoscroll driver cannot disagree about where a
|
|
/// flip should land any more than they can disagree about where the drop should.
|
|
///
|
|
/// **Recorded even when the retarget goes on to hold**, which is the point: a cursor over a gap
|
|
/// or a dead region leaves the proposal exactly where it was, and a flip there must re-ask the
|
|
/// same surface the same question — with the modifiers now saying something else.
|
|
///
|
|
/// Unchanged addresses are not re-stored, `propose`'s own habit and for a sharper reason here:
|
|
/// this runs on every mouse sample *and* every autoscroll frame, and the steady state of a drag
|
|
/// is the same surface answering over and over — a weak reference restored per frame for no
|
|
/// change at all is exactly the kind of hot-path cost the drag model has been shedding.
|
|
func noteRetarget(_ surface: RetargetOrigin.Surface, registry: LaneDropRegistry) {
|
|
if let origin = retargetOrigin, origin.surface == surface, origin.registry === registry {
|
|
return
|
|
}
|
|
retargetOrigin = RetargetOrigin(registry: registry, surface: surface)
|
|
}
|
|
|
|
/// 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 = []
|
|
laneCollapsed = []
|
|
restingLayouts.clear()
|
|
proposal = nil
|
|
operation = .move
|
|
sourceStore = nil
|
|
sourceRoot = nil
|
|
retargetOrigin = nil
|
|
watchdog?.cancel()
|
|
watchdog = nil
|
|
// The monitor's lifetime is the session's, and this is the sentence that makes it true:
|
|
// every path that ends a drag — a delegate's cancel, the button-up belt, the watchdog, the
|
|
// hold's hand-off, the hold's timeout — funnels through here.
|
|
stopFlipWatch()
|
|
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 }
|
|
// **The release pause starts here** (`DragSignposts`): this is the one path that arms a hold,
|
|
// so every begin has an end — a refused release opens nothing. Closed at `handOff`,
|
|
// `expire`, or `endHold`, each with its own outcome.
|
|
#if DEBUG
|
|
DragSignposts.beginReleasePause()
|
|
#endif
|
|
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 }
|
|
#if DEBUG
|
|
DragSignposts.endReleasePause(outcome: "timeout")
|
|
#endif
|
|
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 }
|
|
// The span the release-pause signpost exists for: commit → the covering snapshot.
|
|
#if DEBUG
|
|
DragSignposts.endReleasePause(outcome: "echo")
|
|
#endif
|
|
end()
|
|
}
|
|
|
|
private func endHold() {
|
|
// A no-op for the two paths above, which have already closed their interval with a more
|
|
// specific outcome; the backstop for every other teardown (`end()` from the watchdog, a
|
|
// cancel, a second drag beginning).
|
|
#if DEBUG
|
|
DragSignposts.endReleasePause(outcome: "cleared")
|
|
#endif
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: The modifier flip — the watch, and what a flip means
|
|
|
|
/// Starts watching ⌥/⌘ for this session (`ModifierFlipSource`).
|
|
///
|
|
/// **Armed at `begin` and stopped at `end`, deliberately nowhere else.** The watchdog already
|
|
/// guarantees `end` runs for every session macOS never reports the finish of, so tying the
|
|
/// monitor to that same bracket is what makes "it cannot outlive the drag" structural rather
|
|
/// than a list of call sites to keep in step — the resting-layout cache's precedent exactly.
|
|
/// A previous watch is stopped first for `armWatchdog`'s reason: a second `begin` with a session
|
|
/// somehow still standing must not leave the first one's monitor installed.
|
|
///
|
|
/// The **committed hold** deliberately does not stop it: a hold is still this session, and the
|
|
/// freeze it applies is `resolveOperation`'s and `propose`'s, asked at the flip rather than
|
|
/// spelled a second time in the lifecycle (`noteFlip` restates it anyway, so a flip during a
|
|
/// settle costs not even a body pass).
|
|
private func armFlipWatch() {
|
|
stopFlipWatch()
|
|
// The baseline is the modifier state the drag is *starting* under — a ⌥ that was already
|
|
// down at pickup is not a flip, and `DragLocality` has already seen it.
|
|
flipFlags = NSEvent.modifierFlags.intersection(Self.operationFlags)
|
|
flipWatch = flipSource.watch { [weak self] flags in
|
|
self?.noteFlip(flags)
|
|
}
|
|
}
|
|
|
|
private func stopFlipWatch() {
|
|
flipWatch?.stop()
|
|
flipWatch = nil
|
|
}
|
|
|
|
/// A `.flagsChanged` arrived. Publishes the nudge the hovered board turns back into a
|
|
/// re-proposal, and only when something the operation depends on actually moved.
|
|
///
|
|
/// **The event's location is deliberately unread.** A flip is not a pointer event: the mouse is
|
|
/// exactly where the last drop callback left it, and every retarget reads the *physical* cursor
|
|
/// through its own window (`BoardDropContext.globalCursor`) rather than any event's coordinates.
|
|
/// That is the same reason the autoscroll driver needs no events at all to keep re-proposing.
|
|
///
|
|
/// **A settled release ignores flips**, the third face of the freeze `resolveOperation` and
|
|
/// `propose` already wear: the write named an operation and the overlay draws that operation
|
|
/// until the echo lands, so a ⌥ released between the drop and the reload must not re-lift
|
|
/// originals the write is leaving in place.
|
|
private func noteFlip(_ flags: NSEvent.ModifierFlags) {
|
|
let relevant = flags.intersection(Self.operationFlags)
|
|
guard relevant != flipFlags else { return }
|
|
flipFlags = relevant
|
|
guard isActive, hold == nil else { return }
|
|
modifierGeneration &+= 1
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Watching the modifiers
|
|
|
|
/// Where `DragSession` learns that ⌥ or ⌘ moved — the seam under the local `.flagsChanged` monitor,
|
|
/// and `DragAutoScrollTickSource`'s exact cousin: the shipping implementation needs a live
|
|
/// `NSApplication` event stream, which a test bundle has no way to feed.
|
|
///
|
|
/// `Sendable` so a session's stored source is, like the tick source's; the watch it hands back is
|
|
/// main-actor state and the handler runs there, which is where every drag input is read.
|
|
protocol ModifierFlipSource: Sendable {
|
|
|
|
/// Starts reporting modifier changes, until the returned watch is stopped. The flags are the
|
|
/// event's own — the *whole* set, since deciding which bits matter is the session's job
|
|
/// (`DragSession.operationFlags`).
|
|
@MainActor
|
|
func watch(_ onFlip: @escaping @MainActor (NSEvent.ModifierFlags) -> Void) -> any ModifierFlipWatch
|
|
}
|
|
|
|
/// A running modifier watch. `stop()` is what retires the underlying monitor, and it is idempotent
|
|
/// so the session's teardown can call it on every path without asking whether one is installed.
|
|
@MainActor
|
|
protocol ModifierFlipWatch: AnyObject {
|
|
func stop()
|
|
}
|
|
|
|
/// The shipping source: a **local** `NSEvent` monitor for `.flagsChanged`.
|
|
///
|
|
/// Local rather than global, for two reasons that point the same way. A global monitor for keyboard
|
|
/// events needs Accessibility permission — an enormous ask for a drag affordance — and it would
|
|
/// report modifiers pressed while another app is frontmost, which is not a flip in *this* drag at
|
|
/// all. The events a drag actually needs are the ones being dispatched to this application while it
|
|
/// holds the session.
|
|
struct LocalModifierFlipSource: ModifierFlipSource {
|
|
|
|
@MainActor
|
|
func watch(_ onFlip: @escaping @MainActor (NSEvent.ModifierFlags) -> Void) -> any ModifierFlipWatch {
|
|
LocalModifierFlipWatch(onFlip)
|
|
}
|
|
}
|
|
|
|
/// The monitor token's owner, and the one place it is removed.
|
|
///
|
|
/// `addLocalMonitorForEvents` hands back an opaque token that `removeMonitor` **must** be given —
|
|
/// AppKit retains the handler until it is, so a token dropped on the floor is a block that keeps
|
|
/// firing for the rest of the process. Holding it in an object whose only method retires it is what
|
|
/// makes the removal unmissable: `DragSession.end()` stops the watch, and every way a drag can
|
|
/// finish goes through `end()`.
|
|
@MainActor
|
|
private final class LocalModifierFlipWatch: ModifierFlipWatch {
|
|
|
|
private var token: Any?
|
|
|
|
init(_ onFlip: @escaping @MainActor (NSEvent.ModifierFlags) -> Void) {
|
|
token = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { event in
|
|
// Local monitors run on the main thread, before the event reaches its window.
|
|
MainActor.assumeIsolated { onFlip(event.modifierFlags) }
|
|
// **Returned unchanged, always.** ⌥ and ⌘ mean things to the rest of the app — the
|
|
// click grammar, the menu bar's key equivalents — and a monitor that swallowed them
|
|
// would be reading the drag's modifiers by taking them away from everything else.
|
|
return event
|
|
}
|
|
}
|
|
|
|
func stop() {
|
|
guard let token else { return }
|
|
NSEvent.removeMonitor(token)
|
|
self.token = nil
|
|
}
|
|
}
|