The second half: system drag sessions over phase 1's model, per DRAG-REORDER.md and 04-interactions.md § Drag & drop. - Card faces, lane headers, and trash rows drag as NSItemProvider sessions (two exported UTTypes, JSON payload in flatten order, plain-text titles as the secondary representation) — replacing m4's custom lane-reorder gesture and trash drag-out wholesale; the app-wide DragSession carries the members, the frozen dragged sizes, the live proposal, and the effective operation. - Three drop delegates (lane masonry, strip, window fallback), each accepting both types and routing internally per the single-target-dispatch rule; the cursor is the physical mouse converted to strip space; proposals come from DropSlotMath with hysteresis threaded through, and the lane-strip proposal clamps in front of the shown trash. - Locality picks the default — move within a board, copy across, the badge tracking live; ⌥ forces copy (ignored on within-board lane drags), ⌘ forces move; trash rows restore within their board (positional), copy out across boards by default, ⌘ forcing the true restore-move. - N contiguous shadows with reflow keyed on the proposal; the committed-overlay hold renders the dropped arrangement until the reload echo lands (1.5 s dissolution deadline for refused writes); the re-grounding trio: geometry re-derives per render, proposals re-validate by liveness at release, an emptied drag cancels itself. - Edge autoscroll (ticking driver over DragAutoScrollMath, re-targeting per step), the mouse-up-gated late-event cleanup, and the polling watchdog — the pathfinder's lifecycle traps, ported. - Store: moveLanes and multi-card restoreByDrag join the one-bracket drop commits. 784 unit tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
419 lines
19 KiB
Swift
419 lines
19 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 both layouts, because they differ only in what the container is: `laneID == nil`
|
|
/// is the **lane strip** (the index counts live lanes with the dragged run removed), and a lane id
|
|
/// is that lane's **masonry** (the index is a position in its logical card order — DRAG-REORDER.md
|
|
/// § The card masonry). `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 {
|
|
var boardRoot: URL
|
|
/// `nil` == the lane strip.
|
|
var laneID: ItemID?
|
|
var index: 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.
|
|
///
|
|
/// The `deadline` 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: URL
|
|
|
|
/// 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: URL, generation: Int) -> Bool {
|
|
DragLocality.isSameBoard(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.
|
|
enum DragLocality {
|
|
|
|
/// Whether two roots name the same board. Symlinks are resolved first — a board reached through
|
|
/// a pinned symlink is the same board as the one reached directly (01-storage-format.md's
|
|
/// symlink pins) — and the standardized path is the comparison key.
|
|
static func isSameBoard(_ lhs: URL, _ rhs: URL) -> Bool {
|
|
lhs.resolvingSymlinksInPath().standardizedFileURL.path
|
|
== rhs.resolvingSymlinksInPath().standardizedFileURL.path
|
|
}
|
|
|
|
/// 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 row's drag is copy-out grammar** (▸ The trash). Within its own board it is the
|
|
/// restore — a move, no badge; across boards the default is the live copy that leaves the
|
|
/// tombstoned original in place, exactly as ⌘C out of the trash behaves. ⌘ forces the true
|
|
/// restore-move either way, and ⌥ forces the live copy either way ("⌘C, ⌥-drag, and the
|
|
/// cross-board drag default always yield *live* copies").
|
|
static func operation(
|
|
kind: DragKind,
|
|
side: Liveness,
|
|
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 }
|
|
_ = side // the side 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 side the drag started on. A trash row's drag is a `.cards` session on the `.trashed`
|
|
/// side, and that is the whole of what makes it one (04-interactions.md ▸ The trash).
|
|
private(set) var side: Liveness = .live
|
|
|
|
/// 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: URL?
|
|
|
|
@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] = []
|
|
|
|
// 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 was tombstoned 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?
|
|
|
|
@ObservationIgnored private var watchdog: Task<Void, Never>?
|
|
@ObservationIgnored private var holdTimeout: Task<Void, Never>?
|
|
|
|
init() {}
|
|
|
|
// MARK: Queries
|
|
|
|
var isActive: Bool { kind != nil }
|
|
var isDraggingCards: Bool { kind == .cards }
|
|
var isDraggingLanes: Bool { kind == .lanes }
|
|
|
|
/// 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 live-side session: a trash row's drag
|
|
/// carries items that render in the quasi-lane, not in any lane's masonry, so no lane loses a
|
|
/// card to it.
|
|
///
|
|
/// **The dragged run is lifted out whatever the effective operation is** (DRAG-REORDER.md §
|
|
/// Resting-layout zones): ⌥ can be pressed and released mid-drag, and a layout that re-admitted
|
|
/// the originals on every modifier flip would flap the whole board under the cursor. The copy's
|
|
/// originals reappear when the write lands.
|
|
func hiddenMembers(onBoardRooted root: URL) -> Set<ItemID> {
|
|
guard isActive, side == .live, let sourceRoot,
|
|
DragLocality.isSameBoard(root, sourceRoot)
|
|
else { return [] }
|
|
return 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: URL) -> Int? {
|
|
guard kind == .lanes, let proposal, proposal.laneID == nil,
|
|
DragLocality.isSameBoard(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: URL, laneID: ItemID) -> Int? {
|
|
guard kind == .cards, let proposal, proposal.laneID == laneID,
|
|
DragLocality.isSameBoard(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: Lifecycle
|
|
|
|
/// Begins a card session — live faces or trash rows.
|
|
///
|
|
/// - Parameters:
|
|
/// - members: the dragged cards in flatten order (`SelectionGrammar.liveCards`, or the trash's
|
|
/// own sorted order for a trash-row drag).
|
|
/// - heights: their measured heights, captured **before** the pickup transition starts.
|
|
func beginCards(
|
|
_ members: [ItemID],
|
|
folders: [URL],
|
|
heights: [CGFloat],
|
|
side: Liveness,
|
|
source: BoardStore
|
|
) {
|
|
begin(kind: .cards, members: members, folders: folders, side: side, source: source)
|
|
cardHeights = heights
|
|
laneUnits = []
|
|
}
|
|
|
|
/// Begins a lane session.
|
|
func beginLanes(_ members: [ItemID], folders: [URL], units: [Int], source: BoardStore) {
|
|
begin(kind: .lanes, members: members, folders: folders, side: .live, source: source)
|
|
laneUnits = units
|
|
cardHeights = []
|
|
}
|
|
|
|
private func begin(
|
|
kind: DragKind,
|
|
members: [ItemID],
|
|
folders: [URL],
|
|
side: Liveness,
|
|
source: BoardStore
|
|
) {
|
|
endHold()
|
|
self.kind = kind
|
|
self.members = members
|
|
self.memberSet = Set(members)
|
|
self.folders = folders
|
|
self.side = side
|
|
self.sourceStore = source
|
|
self.sourceRoot = source.rootURL
|
|
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, liveness: side)
|
|
armWatchdog()
|
|
}
|
|
|
|
/// Records a new proposal. `nil` withdraws it — rule 2's "the shadow withdraws".
|
|
func propose(_ target: DropTarget?) {
|
|
guard isActive, 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.
|
|
@discardableResult
|
|
func resolveOperation(destinationRoot: URL) -> TransferOperation {
|
|
guard let kind, let sourceRoot else { return operation }
|
|
let resolved = DragLocality.operation(
|
|
kind: kind,
|
|
side: side,
|
|
isWithinBoard: DragLocality.isSameBoard(sourceRoot, destinationRoot),
|
|
modifiers: NSEvent.modifierFlags
|
|
)
|
|
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 = []
|
|
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 — is kept
|
|
/// exactly as it was, so "keeps rendering the arrangement it was showing" needs no second
|
|
/// mechanism: the shadows stay at the landing slots and the originals stay lifted out until the
|
|
/// snapshot carrying the write arrives and the real faces take their place.
|
|
func commit(into store: BoardStore) {
|
|
guard isActive else { return }
|
|
sourceStore?.transient.dragMembers = .empty
|
|
watchdog?.cancel()
|
|
watchdog = nil
|
|
let hold = CommittedHold(boardRoot: store.rootURL, generation: store.snapshotGeneration)
|
|
self.hold = hold
|
|
holdTimeout?.cancel()
|
|
holdTimeout = Task { @MainActor [weak self] in
|
|
try? await Task.sleep(for: CommittedHold.timeout)
|
|
guard !Task.isCancelled, let self, self.hold == hold else { return }
|
|
withAnimation(Motion.dragReflow(reduced: Motion.prefersReducedMotion)) { self.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: URL, generation: Int) {
|
|
guard let hold, hold.isRetired(byRoot: root, generation: generation) else { return }
|
|
end()
|
|
}
|
|
|
|
private func endHold() {
|
|
hold = nil
|
|
holdTimeout?.cancel()
|
|
holdTimeout = 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
|
|
}
|
|
}
|
|
}
|
|
}
|