Implement drag & drop with the locality model
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
This commit is contained in:
@@ -165,6 +165,17 @@ public final class AppModel {
|
||||
/// board-scoped and this list deliberately is not.
|
||||
public let styleRecents = StyleRecents()
|
||||
|
||||
/// The app's one drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
||||
///
|
||||
/// App-wide for the reason cross-board drags exist at all: **a drag crosses windows**, so the
|
||||
/// source board hides the dragged items while any other open board's drop delegates propose a
|
||||
/// landing spot for them. It lives here rather than as a global for `styleRecents`' reason — a
|
||||
/// test holds its own rather than colliding with the app's — and every board window reaches it
|
||||
/// through the environment.
|
||||
/// Internal rather than `public`, unlike its neighbours: the drag is entirely a UI-layer
|
||||
/// concern, and nothing outside this module has any business reaching into a gesture in flight.
|
||||
let dragSession = DragSession()
|
||||
|
||||
// MARK: Sessions
|
||||
|
||||
/// One open board window and everything hanging off it.
|
||||
|
||||
@@ -44,6 +44,30 @@
|
||||
<string>kanban</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<!-- The drag session payload types (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
||||
Same-app transfer is the only real consumer — both boards are open in this app — but a
|
||||
system drag session is what crosses window boundaries, draws the copy badge and gives
|
||||
the full-size replica, and a system session needs a declared type to carry. -->
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>dev.rzen.indie.kanban.cards</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>Lanework Cards</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>dev.rzen.indie.kanban.lanes</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>Lanework Lanes</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
|
||||
@@ -133,6 +133,15 @@ public final class BoardStore {
|
||||
/// path — see the type's doc comment for why. A failed reload leaves it exactly as it was.
|
||||
public private(set) var snapshot: BoardModel
|
||||
|
||||
/// How many snapshots this store has applied, ever — a counter, not a version.
|
||||
///
|
||||
/// It exists for **the committed-overlay hold** (DRAG-REORDER.md § The committed-overlay hold):
|
||||
/// a drop's overlay stands until "the next snapshot application on that store", and *application*
|
||||
/// is the event, not change. A reload that produced an identical model still ends the round trip
|
||||
/// the overlay was covering — comparing `snapshot` values would leave the overlay standing
|
||||
/// exactly when the write turned out to be a no-op.
|
||||
public private(set) var snapshotGeneration: Int = 0
|
||||
|
||||
/// Tolerated anomalies from the load that produced `snapshot` (stray folders, an indexless
|
||||
/// UUID-shaped folder, a board-level `deleted:`). Replaced with the snapshot, so they always
|
||||
/// describe the tree currently on screen.
|
||||
@@ -449,6 +458,7 @@ public final class BoardStore {
|
||||
reduced: Motion.prefersReducedMotion
|
||||
)) {
|
||||
snapshot = result.model
|
||||
snapshotGeneration += 1
|
||||
loadWarnings = result.warnings
|
||||
// The one place transient state is re-grounded. It goes last, after `snapshot` is
|
||||
// the new one, because a view woken by the snapshot's change must never observe a
|
||||
@@ -1133,7 +1143,7 @@ public final class BoardStore {
|
||||
|
||||
/// Commits a lane drag: `id` lands at display position `index` among the board's live lanes,
|
||||
/// counted **with the dragged lane itself removed** — which is the index
|
||||
/// `LaneReorderMath.proposedIndex` produces.
|
||||
/// `DropSlotMath.laneSlot` produces.
|
||||
///
|
||||
/// Within-board only. A cross-board lane drag is the locality model's (04-interactions.md ▸
|
||||
/// Drag and drop) and belongs to m5's drag card; here source and destination board roots are
|
||||
@@ -1177,6 +1187,57 @@ public final class BoardStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// The within-board **lane drag**, multi-drag included: `ids` land contiguously at display
|
||||
/// position `index` among the board's live lanes, counted with the dragged run removed — the
|
||||
/// index `DropSlotMath.laneSlot` produces.
|
||||
///
|
||||
/// `moveLane`'s plural, and it exists rather than a loop over it because "one `performWrite`
|
||||
/// bracket per gesture whatever the set's size" is load-bearing (DRAG-REORDER.md § The drop
|
||||
/// commits): one app-mediated reload, and on git boards one commit rather than N.
|
||||
///
|
||||
/// The run keeps **board order**, which is the lane level's flatten order — a multi-lane drag has
|
||||
/// no other relative order to preserve.
|
||||
///
|
||||
/// A drag that changes nothing writes nothing, stated as the arrangement rather than as a special
|
||||
/// case: if the strip would render exactly what it renders now, no rank is rewritten and no
|
||||
/// commit is minted.
|
||||
public func moveLanes(_ ids: Set<ItemID>, toIndex index: Int) {
|
||||
let lanes = snapshot.lanes.filter { !$0.isDeleted }
|
||||
let members = lanes.filter { ids.contains($0.id) }
|
||||
guard !members.isEmpty else { return }
|
||||
|
||||
let remaining = lanes.filter { !ids.contains($0.id) }
|
||||
let target = min(max(0, index), remaining.count)
|
||||
guard DropSlotMath.applied(lanes.map(\.id), moving: members.map(\.id), to: target) != lanes.map(\.id)
|
||||
else { return }
|
||||
|
||||
let root = rootURL
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var ranks = Ranks.insertionRanks(amongVisible: remaining.map(\.order), at: target, count: members.count)
|
||||
if ranks == nil {
|
||||
// Compact and place again. The dragged lanes *are* among the renumbered children —
|
||||
// they are real folders on disk — so their fresh rungs are dropped from the ladder
|
||||
// before the neighbours are consulted, exactly as `moveLane` drops its one.
|
||||
try BoardWriter.renumberVisibleChildren(of: root)
|
||||
let compacted = zip(lanes, Ranks.renumbered(count: lanes.count))
|
||||
.filter { !ids.contains($0.0.id) }
|
||||
.map(\.1)
|
||||
ranks = Ranks.insertionRanks(amongVisible: compacted, at: target, count: members.count)
|
||||
}
|
||||
guard let ranks else { return }
|
||||
|
||||
for (member, rank) in zip(members, ranks) {
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: root.appendingPathComponent(member.id.rawValue, isDirectory: true),
|
||||
toParent: root,
|
||||
sourceBoardRoot: root,
|
||||
destinationBoardRoot: root,
|
||||
order: rank
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drag & drop commits
|
||||
|
||||
// The writes a released drag performs (04-interactions.md ▸ Drag and drop; the geometry that
|
||||
@@ -1758,52 +1819,77 @@ public final class BoardStore {
|
||||
/// destination lane that is gone or tombstoned, a card that is not a trash row (its own flag
|
||||
/// unset, or its lane tombstoned so it has no row to drag), and an id that names nothing.
|
||||
public func restoreByDrag(cardID: ItemID, intoLane laneID: ItemID, at index: Int) {
|
||||
guard let destination = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }),
|
||||
let source = snapshot.lanes.first(where: { lane in
|
||||
!lane.isDeleted && lane.cards.contains { $0.id == cardID && $0.isDeleted }
|
||||
}),
|
||||
let card = source.cards.first(where: { $0.id == cardID })
|
||||
else { return }
|
||||
restoreByDrag(cardIDs: [cardID], intoLane: laneID, at: index)
|
||||
}
|
||||
|
||||
/// The multi-drag face of the same gesture: N trash rows dropped over one live lane land
|
||||
/// contiguously at `index`, **in drop order** — the order the payload carries, which is the
|
||||
/// trash's own sorted order (03-board-ui.md § Trash ▸ Contents).
|
||||
///
|
||||
/// It is the plural rather than a loop over the singular for `moveLanes`' reason: one
|
||||
/// `performWrite` bracket per gesture whatever the set's size, so one reload and one commit
|
||||
/// (DRAG-REORDER.md § The drop commits). Every rule above holds per member — the same-lane
|
||||
/// single write, the cross-lane restore-then-move pair, and the recorded-`order` preservation,
|
||||
/// which is what makes a row dropped back where it already belonged come back exactly there.
|
||||
public func restoreByDrag(cardIDs: [ItemID], intoLane laneID: ItemID, at index: Int) {
|
||||
guard let destination = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) else { return }
|
||||
|
||||
// A row exists only for a card whose *own* flag is set under a *live* lane — the trash's
|
||||
// absolute ancestor walk (03-board-ui.md § Trash ▸ Contents). Anything else in the list names
|
||||
// nothing draggable and is silently skipped, which is this method's standing posture.
|
||||
let rows: [(laneID: ItemID, card: Card)] = cardIDs.compactMap { id in
|
||||
guard let source = snapshot.lanes.first(where: { lane in
|
||||
!lane.isDeleted && lane.cards.contains { $0.id == id && $0.isDeleted }
|
||||
}),
|
||||
let card = source.cards.first(where: { $0.id == id })
|
||||
else { return nil }
|
||||
return (source.id, card)
|
||||
}
|
||||
guard !rows.isEmpty else { return }
|
||||
|
||||
let root = rootURL
|
||||
let cardFolder = TrashModel.ItemPath(laneID: source.id, cardID: cardID).folder(under: root)
|
||||
let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
let crossesLanes = source.id != laneID
|
||||
|
||||
let rendered = destination.cards.filter { !$0.isDeleted }
|
||||
let target = min(max(0, index), rendered.count)
|
||||
let recordedOrder = card.order
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var rank = Ranks.insertionRank(amongVisible: rendered.map(\.order), at: target)
|
||||
if rank == nil {
|
||||
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: target, count: rows.count)
|
||||
if ranks == nil {
|
||||
try BoardWriter.renumberVisibleChildren(of: laneFolder)
|
||||
rank = Ranks.insertionRank(amongVisible: Ranks.renumbered(count: rendered.count), at: target)
|
||||
ranks = Ranks.insertionRanks(
|
||||
amongVisible: Ranks.renumbered(count: rendered.count),
|
||||
at: target,
|
||||
count: rows.count
|
||||
)
|
||||
}
|
||||
guard let rank else { return }
|
||||
guard let ranks else { return }
|
||||
|
||||
guard crossesLanes else {
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: cardFolder,
|
||||
// `.restore(title: nil)`: `updateIndex` enriches it off the document it reads.
|
||||
operation: .restore(title: nil)
|
||||
) { document in
|
||||
document.remove(FrontmatterKeys.deleted)
|
||||
if rank != recordedOrder {
|
||||
document.set(FrontmatterKeys.order, to: .double(rank))
|
||||
for (row, rank) in zip(rows, ranks) {
|
||||
let cardFolder = TrashModel.ItemPath(laneID: row.laneID, cardID: row.card.id).folder(under: root)
|
||||
|
||||
guard row.laneID != laneID else {
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: cardFolder,
|
||||
// `.restore(title: nil)`: `updateIndex` enriches it off the document it reads.
|
||||
operation: .restore(title: nil)
|
||||
) { document in
|
||||
document.remove(FrontmatterKeys.deleted)
|
||||
if rank != row.card.order {
|
||||
document.set(FrontmatterKeys.order, to: .double(rank))
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try BoardWriter.restoreItem(at: cardFolder)
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: cardFolder,
|
||||
toParent: laneFolder,
|
||||
sourceBoardRoot: root,
|
||||
destinationBoardRoot: root,
|
||||
order: rank
|
||||
)
|
||||
try BoardWriter.restoreItem(at: cardFolder)
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: cardFolder,
|
||||
toParent: laneFolder,
|
||||
sourceBoardRoot: root,
|
||||
destinationBoardRoot: root,
|
||||
order: rank
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
// MARK: - What each lane draws, as the drag reads it
|
||||
|
||||
/// Where each lane's card grid is drawn and how tall its cards are — the measured half of the card
|
||||
/// masonry's drop geometry, one registry per board window.
|
||||
///
|
||||
/// **Deliberately not `@Observable`.** Nothing renders off it: it exists so a drop delegate and the
|
||||
/// autoscroll driver can ask, at *event* time, where the grid is and what the resting row extents
|
||||
/// are. Observing it would invalidate the strip on every layout pass, which is the animation
|
||||
/// feedback loop this whole model exists to avoid.
|
||||
///
|
||||
/// ### What is measured here, and why that is animation-proof
|
||||
///
|
||||
/// 03-board-ui.md § Motion forbids reading *mid-flight* measurements. Two things are read here and
|
||||
/// neither is one:
|
||||
///
|
||||
/// - **The grid's frame.** A lane's card area does not move while a card session is in flight: the
|
||||
/// masonry reflows *inside* it, and the strip only reflows for a lane session, which reads none of
|
||||
/// this.
|
||||
/// - **Each card's height.** A card's height is content-driven — the column width is fixed by the
|
||||
/// lane's unit count — so it does not animate under the reflow; only positions do. The positions
|
||||
/// are never measured: they are replayed analytically from these heights through
|
||||
/// `MasonryPlacement.frames(heights:)`, which is the very function `MasonryLayout` places with
|
||||
/// (DRAG-REORDER.md § The card masonry).
|
||||
///
|
||||
/// The one input that *is* frozen at drag start is the **dragged** cards' own heights, which live on
|
||||
/// `DragSession`: the pickup transition scales the replica and corrupts its last measured frame.
|
||||
@MainActor
|
||||
final class LaneDropRegistry {
|
||||
|
||||
/// One lane's masonry, as drawn.
|
||||
struct Grid: Equatable, Sendable {
|
||||
/// The card area's frame in the window's SwiftUI global space — the space the physical
|
||||
/// cursor is converted into (`BoardDropContext.globalCursor`).
|
||||
var frame: CGRect
|
||||
/// Interior masonry columns — the lane's width units.
|
||||
var columns: Int
|
||||
/// Spacing between columns and between stacked cards.
|
||||
var spacing: CGFloat
|
||||
}
|
||||
|
||||
/// The height a card with no registered measurement is assumed to have — a lane whose faces have
|
||||
/// not laid out yet. Nominal rather than zero, so the resting rows still tile.
|
||||
static let nominalCardHeight: CGFloat = 44
|
||||
|
||||
/// The board strip's own frame in the window's SwiftUI global space — the origin the strip
|
||||
/// coordinates `DropSlotMath.laneExtents` is written in are measured from. It lives here rather
|
||||
/// than in the view's `@State` so a delegate reads the *current* rectangle at event time.
|
||||
var stripFrame: CGRect = .zero
|
||||
|
||||
private(set) var grids: [ItemID: Grid] = [:]
|
||||
private(set) var heights: [ItemID: CGFloat] = [:]
|
||||
|
||||
func update(_ grid: Grid, for laneID: ItemID) { grids[laneID] = grid }
|
||||
func removeGrid(_ laneID: ItemID) { grids.removeValue(forKey: laneID) }
|
||||
|
||||
func update(height: CGFloat, for cardID: ItemID) { heights[cardID] = height }
|
||||
func removeHeight(_ cardID: ItemID) { heights.removeValue(forKey: cardID) }
|
||||
}
|
||||
|
||||
// MARK: - The board window's half of a drop
|
||||
|
||||
/// Everything a drop delegate — and the autoscroll driver — needs to answer "where would this land",
|
||||
/// read at **event** time rather than captured at body-evaluation time.
|
||||
///
|
||||
/// The closures are the point: a captured snapshot of the strip's frame or its standard width goes
|
||||
/// stale the moment the layout animates, and two delegates holding different snapshots would flap
|
||||
/// the proposal between them.
|
||||
@MainActor
|
||||
struct BoardDropContext {
|
||||
|
||||
let store: BoardStore
|
||||
let session: DragSession
|
||||
let registry: LaneDropRegistry
|
||||
|
||||
/// The strip's inter-lane gap, which is also its outer margin.
|
||||
let gap: CGFloat
|
||||
|
||||
/// The window hosting this board — the physical cursor is converted through it.
|
||||
let window: @MainActor () -> NSWindow?
|
||||
|
||||
/// The strip's frame in the window's SwiftUI global space.
|
||||
let stripFrame: @MainActor () -> CGRect
|
||||
|
||||
/// The strip's 1× lane width for the current drag context (`LaneLayoutMath.standardWidth`).
|
||||
let standard: @MainActor () -> CGFloat
|
||||
|
||||
// MARK: The cursor
|
||||
|
||||
/// The physical cursor in the window's SwiftUI global space.
|
||||
///
|
||||
/// **`NSEvent.mouseLocation`, never `DropInfo.location`** (DRAG-REORDER.md § Animation-proof
|
||||
/// inputs): the drop callback's location is expressed in the target view's space, and that view
|
||||
/// may itself be mid-reflow. This is also what `LaneResizeSession` and `MarqueeSession` already
|
||||
/// read.
|
||||
func globalCursor() -> CGPoint? {
|
||||
guard let window = window(), let content = window.contentView else { return nil }
|
||||
let inWindow = window.convertPoint(fromScreen: NSEvent.mouseLocation)
|
||||
let inContent = content.convert(inWindow, from: nil)
|
||||
// SwiftUI's global space is top-left-origin; an unflipped `NSView` is bottom-left.
|
||||
let y = content.isFlipped ? inContent.y : content.bounds.height - inContent.y
|
||||
return CGPoint(x: inContent.x, y: y)
|
||||
}
|
||||
|
||||
/// The cursor in strip coordinates — 0 at the strip's leading edge, outer margin included, which
|
||||
/// is the origin `DropSlotMath.laneExtents` assumes.
|
||||
func stripCursor() -> CGPoint? {
|
||||
guard let cursor = globalCursor() else { return nil }
|
||||
let frame = stripFrame()
|
||||
return CGPoint(x: cursor.x - frame.minX, y: cursor.y - frame.minY)
|
||||
}
|
||||
|
||||
// MARK: Re-grounding
|
||||
|
||||
/// **Rule 2 of the mid-drag re-grounding trio** (04-interactions.md ▸ Drag and drop): a proposal
|
||||
/// whose target lane was tombstoned or vanished in a reload is invalidated — tombstoned lanes are
|
||||
/// never drop targets — the shadow withdraws, and no proposal stands until the pointer reaches a
|
||||
/// live target.
|
||||
///
|
||||
/// Run at the top of every callback *and* again at release, against the snapshot as it is then;
|
||||
/// the store's commits enforce the same rule independently, so the gesture and the write cannot
|
||||
/// disagree.
|
||||
func revalidateProposal() {
|
||||
guard let proposal = session.proposal,
|
||||
DragLocality.isSameBoard(proposal.boardRoot, store.rootURL),
|
||||
let laneID = proposal.laneID
|
||||
else { return }
|
||||
guard !store.snapshot.lanes.contains(where: { $0.id == laneID && !$0.isDeleted }) else { return }
|
||||
session.propose(nil)
|
||||
}
|
||||
|
||||
// MARK: Retargeting — the one shared answer
|
||||
|
||||
/// Where a **lane** session would land on this board's strip.
|
||||
///
|
||||
/// The zones are analytic — `DropSlotMath.laneExtents` over the remaining lanes' unit counts and
|
||||
/// this strip's standard width — and the cursor is the physical mouse, so neither input is a
|
||||
/// measured frame (03-board-ui.md § Motion).
|
||||
///
|
||||
/// **The terminal slot is clamped before the trash.** The quasi-lane consumes one unit while
|
||||
/// shown and is never a landing spot for anything (04-interactions.md ▸ The trash: "no move or
|
||||
/// paste ever targets the trash"), so it is absent from the slot list by construction and the end
|
||||
/// slot's uncapped reach past the last real lane lands *before* it.
|
||||
func retargetLanes() {
|
||||
guard session.isDraggingLanes, let cursor = stripCursor() else { return }
|
||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
||||
let resting = store.snapshot.lanes.filter { !$0.isDeleted && !hidden.contains($0.id) }
|
||||
let restingUnits = resting.map { LaneLayoutMath.displayUnits(of: $0) }
|
||||
let slot = DropSlotMath.laneSlot(
|
||||
cursorX: cursor.x,
|
||||
restingUnits: restingUnits,
|
||||
draggedUnits: session.laneUnits,
|
||||
standard: standard(),
|
||||
gap: gap,
|
||||
current: session.stripProposal(onBoardRooted: store.rootURL)
|
||||
)
|
||||
guard let slot else { return } // a dead region: hold the current proposal
|
||||
let index = min(max(0, slot), restingUnits.count)
|
||||
session.propose(DropTarget(boardRoot: store.rootURL, laneID: nil, index: index))
|
||||
}
|
||||
|
||||
/// Where a **card** session would land in `laneID`'s masonry.
|
||||
///
|
||||
/// The single answer the lane's own drop delegate, the strip's fall-through, and the autoscroll
|
||||
/// driver all go through — "the lane's drop delegate and the autoscroll driver must go through
|
||||
/// one shared retarget, so they can never disagree" (DRAG-REORDER.md § Edge autoscroll).
|
||||
func retargetCards(inLane laneID: ItemID) {
|
||||
guard session.isDraggingCards, let cursor = globalCursor() else { return }
|
||||
guard let lane = store.snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) else {
|
||||
revalidateProposal()
|
||||
return
|
||||
}
|
||||
guard let grid = registry.grids[laneID] else { return }
|
||||
|
||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
||||
let rendered = lane.cards.filter { !$0.isDeleted && !hidden.contains($0.id) }
|
||||
let heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight }
|
||||
let placement = MasonryPlacement(
|
||||
columnCount: grid.columns,
|
||||
columnWidth: MasonryPlacement.columnWidth(
|
||||
totalWidth: grid.frame.width, columnCount: grid.columns, spacing: grid.spacing),
|
||||
spacing: grid.spacing,
|
||||
origin: grid.frame.origin
|
||||
)
|
||||
let slot = DropSlotMath.cardSlot(
|
||||
cursor: cursor,
|
||||
placement: placement,
|
||||
heights: heights,
|
||||
// The run's footprint at the landing spot: the first dragged card's frozen height, which
|
||||
// is the trigger rect the cursor is over (the rest stack below it).
|
||||
draggedHeight: session.cardHeights.first ?? LaneDropRegistry.nominalCardHeight,
|
||||
current: session.laneProposal(onBoardRooted: store.rootURL, laneID: laneID)
|
||||
)
|
||||
guard let slot else { return } // a dead region: hold
|
||||
session.propose(DropTarget(boardRoot: store.rootURL, laneID: laneID, index: slot))
|
||||
}
|
||||
|
||||
/// The strip's fall-through for card sessions: which lane is under the cursor, analytically.
|
||||
///
|
||||
/// This is both the safety net for a lane whose own drop region goes dead (DRAG-REORDER.md §
|
||||
/// Single-target dispatch) and the live handler for the strip's own regions. A cursor over a gap,
|
||||
/// the outer margin, or the trash column is over no lane at all — `LaneLayoutMath.laneIndex`
|
||||
/// answers `nil` there — and the proposal simply **holds**, which is the hysteresis contract.
|
||||
func retargetCardsFromStrip() {
|
||||
guard session.isDraggingCards, let cursor = stripCursor() else { return }
|
||||
let lanes = store.snapshot.lanes.filter { !$0.isDeleted }
|
||||
let index = LaneLayoutMath.laneIndex(
|
||||
atX: cursor.x,
|
||||
unitCounts: lanes.map { LaneLayoutMath.displayUnits(of: $0) },
|
||||
standard: standard(),
|
||||
gap: gap
|
||||
)
|
||||
guard let index, lanes.indices.contains(index) else { return }
|
||||
retargetCards(inLane: lanes[index].id)
|
||||
}
|
||||
|
||||
/// The retarget for whichever session type is in flight, from the strip's own surfaces.
|
||||
func retargetFromStrip() {
|
||||
revalidateProposal()
|
||||
if session.isDraggingLanes {
|
||||
retargetLanes()
|
||||
} else {
|
||||
retargetCardsFromStrip()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: The drop proposal the badge tracks
|
||||
|
||||
/// What `dropUpdated` answers: the effective operation while the shadows are on *this* board,
|
||||
/// `.cancel` otherwise.
|
||||
///
|
||||
/// The operation is re-resolved here rather than at pickup, which is what makes the badge track
|
||||
/// live as the cursor crosses a board boundary (04-interactions.md ▸ Drag and drop).
|
||||
func dropProposal() -> DropProposal {
|
||||
guard let proposal = session.proposal,
|
||||
DragLocality.isSameBoard(proposal.boardRoot, store.rootURL)
|
||||
else { return DropProposal(operation: .cancel) }
|
||||
let operation = session.resolveOperation(destinationRoot: store.rootURL)
|
||||
return DropProposal(operation: operation == .copy ? .copy : .move)
|
||||
}
|
||||
|
||||
// MARK: The commit
|
||||
|
||||
/// Commits the current proposal — **the drop always lands exactly where the shadows show**
|
||||
/// (DRAG-REORDER.md § The pieces).
|
||||
///
|
||||
/// The three re-grounding rules are applied here, against the snapshot as it is *now* rather than
|
||||
/// against whatever the last render believed:
|
||||
///
|
||||
/// 1. the geometry was re-derived on every sample and the proposal is what it produced;
|
||||
/// 2. a proposal naming a vanished or tombstoned lane is invalidated, and **release with no valid
|
||||
/// proposal cancels** — items return, nothing is written;
|
||||
/// 3. an emptied drag cancels itself, and a partly emptied one drops the survivors.
|
||||
///
|
||||
/// The commit is the **destination** store's, one `performWrite` bracket per gesture whatever the
|
||||
/// set's size (DRAG-REORDER.md § The drop commits).
|
||||
func commitDrop() -> Bool {
|
||||
guard session.isActive, let kind = session.kind, let sourceRoot = session.sourceRoot else {
|
||||
return false
|
||||
}
|
||||
revalidateProposal()
|
||||
guard let target = session.proposal,
|
||||
DragLocality.isSameBoard(target.boardRoot, store.rootURL)
|
||||
else {
|
||||
cancelDrop()
|
||||
return false
|
||||
}
|
||||
let survivors = session.survivors
|
||||
guard !survivors.isEmpty else {
|
||||
cancelDrop()
|
||||
return false
|
||||
}
|
||||
let ids = survivors.map { session.members[$0] }
|
||||
let folders = survivors.map { session.folders[$0] }
|
||||
let within = DragLocality.isSameBoard(sourceRoot, store.rootURL)
|
||||
let operation = session.resolveOperation(destinationRoot: store.rootURL)
|
||||
|
||||
switch kind {
|
||||
case .lanes:
|
||||
if within {
|
||||
store.moveLanes(Set(ids), toIndex: target.index)
|
||||
} else {
|
||||
store.receiveLanes(folders, operation: operation, at: target.index)
|
||||
}
|
||||
|
||||
case .cards:
|
||||
guard let laneID = target.laneID else {
|
||||
cancelDrop()
|
||||
return false
|
||||
}
|
||||
switch (session.side, within) {
|
||||
case (.live, true):
|
||||
if operation == .copy {
|
||||
store.copyCards(Set(ids), toLane: laneID, at: target.index)
|
||||
} else {
|
||||
store.moveCards(Set(ids), toLane: laneID, at: target.index)
|
||||
}
|
||||
case (.live, false):
|
||||
store.receiveCards(folders, operation: operation, toLane: laneID, at: target.index)
|
||||
case (.trashed, true):
|
||||
// Drag-to-restore, and its ⌥ twin. "Dropping a tombstoned card into one of its own
|
||||
// board's lanes restores it at the drop position"; ⌥ is the copy-out instead — a
|
||||
// live copy lands and the tombstoned original stays (04-interactions.md ▸ The trash,
|
||||
// "⌘C, ⌥-drag … always yield live copies").
|
||||
if operation == .copy {
|
||||
store.receiveRestoredCards(folders, operation: .copy, toLane: laneID, at: target.index)
|
||||
} else {
|
||||
store.restoreByDrag(cardIDs: ids, intoLane: laneID, at: target.index)
|
||||
}
|
||||
case (.trashed, false):
|
||||
store.receiveRestoredCards(folders, operation: operation, toLane: laneID, at: target.index)
|
||||
}
|
||||
}
|
||||
|
||||
// The committed-overlay hold: keep drawing the arrangement until this store's next snapshot.
|
||||
session.commit(into: store)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Release with nothing valid to write: the items return and nothing is written.
|
||||
func cancelDrop() {
|
||||
withAnimation(Motion.dragReflow(reduced: Motion.prefersReducedMotion)) { session.end() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drop delegates
|
||||
|
||||
// **Single-target dispatch** (DRAG-REORDER.md, the constraint of the same name): SwiftUI/macOS
|
||||
// delivers a drag session to the *deepest* drop region under the cursor with no fall-through, not
|
||||
// even when that target's declared content types don't match the session's payload. So every
|
||||
// delegate below accepts **both** board types and routes internally; a region whose topmost target
|
||||
// understood only one of them would be a dead zone for the other — no hover callbacks, and a release
|
||||
// there would snap back instead of committing.
|
||||
//
|
||||
// m5-finder-drops: the next card adds external Finder file sessions (`.fileURL`) to this same
|
||||
// dispatch — files onto a card become attachments, files onto lane empty space become cards
|
||||
// (04-interactions.md ▸ Drag and drop) — and the same dead-region rule applies to them, so the type
|
||||
// list and the routing switch in each delegate below grow by one case rather than gaining a delegate
|
||||
// of their own.
|
||||
|
||||
/// The board types every delegate declares. Spelled once so no target can accidentally accept fewer.
|
||||
let boardDragTypes: [UTType] = [.laneworkCards, .laneworkLanes]
|
||||
|
||||
/// One lane's drop target, attached to the whole lane body.
|
||||
///
|
||||
/// **Card sessions** resolve against this lane's masonry zones. **Lane sessions** are forwarded to
|
||||
/// the strip's logic (cursor converted to strip space by the shared context), so lane reordering
|
||||
/// keeps working while the cursor crosses lane bodies.
|
||||
struct LaneDropDelegate: DropDelegate {
|
||||
|
||||
// m5-finder-drops: a file session resolves against these same masonry zones — onto a card it
|
||||
// becomes attachments, onto empty space a card per file (04-interactions.md ▸ Drag and drop).
|
||||
|
||||
let context: BoardDropContext
|
||||
let laneID: ItemID
|
||||
|
||||
func validateDrop(info: DropInfo) -> Bool {
|
||||
context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
|
||||
}
|
||||
|
||||
func dropEntered(info: DropInfo) { retarget() }
|
||||
|
||||
func dropUpdated(info: DropInfo) -> DropProposal? {
|
||||
retarget()
|
||||
return context.dropProposal()
|
||||
}
|
||||
|
||||
// No `dropExited`, deliberately: the proposal is meant to **hold** while the cursor leaves for
|
||||
// ambiguous territory — that is the hysteresis contract (DRAG-REORDER.md § Hysteresis).
|
||||
|
||||
func performDrop(info: DropInfo) -> Bool {
|
||||
context.commitDrop()
|
||||
}
|
||||
|
||||
private func retarget() {
|
||||
context.revalidateProposal()
|
||||
if context.session.isDraggingLanes {
|
||||
context.retargetLanes()
|
||||
} else {
|
||||
context.retargetCards(inLane: laneID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The strip's drop target — the backdrop, the gaps, the outer margin, and the trash column's
|
||||
/// footprint, which is never a landing spot of its own (04-interactions.md ▸ The trash) and so
|
||||
/// simply falls through to here.
|
||||
///
|
||||
/// Lane sessions retarget against the strip's analytic zones; card sessions retarget through the
|
||||
/// same shared function the lane delegates use, resolving the lane under the cursor analytically —
|
||||
/// the safety net for a lane whose own drop region goes dead.
|
||||
struct StripDropDelegate: DropDelegate {
|
||||
|
||||
// m5-finder-drops: the strip is a file session's safety net too — the same dead-region
|
||||
// hit-testing bug can strand one, and without file support here it would have nowhere to land.
|
||||
|
||||
let context: BoardDropContext
|
||||
|
||||
func validateDrop(info: DropInfo) -> Bool {
|
||||
context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
|
||||
}
|
||||
|
||||
func dropEntered(info: DropInfo) { context.retargetFromStrip() }
|
||||
|
||||
func dropUpdated(info: DropInfo) -> DropProposal? {
|
||||
context.retargetFromStrip()
|
||||
return context.dropProposal()
|
||||
}
|
||||
|
||||
func performDrop(info: DropInfo) -> Bool {
|
||||
context.commitDrop()
|
||||
}
|
||||
}
|
||||
|
||||
/// The window-level fallback, behind every specific target: a release over any in-window region they
|
||||
/// do not cover (the banner strip, the window's edges) commits the current proposal rather than
|
||||
/// leaking the session into a cancel-snapback. It retargets nothing — the drop lands where the
|
||||
/// shadows already show, which is what the shadows promise.
|
||||
struct BoardFallbackDropDelegate: DropDelegate {
|
||||
|
||||
// m5-finder-drops: a file session released over uncovered window chrome commits whatever the
|
||||
// file target currently names, exactly as this commits the current card/lane proposal.
|
||||
|
||||
let context: BoardDropContext
|
||||
|
||||
func validateDrop(info: DropInfo) -> Bool {
|
||||
context.session.isActive && info.hasItemsConforming(to: boardDragTypes)
|
||||
}
|
||||
|
||||
func dropUpdated(info: DropInfo) -> DropProposal? {
|
||||
context.dropProposal()
|
||||
}
|
||||
|
||||
func performDrop(info: DropInfo) -> Bool {
|
||||
context.commitDrop()
|
||||
}
|
||||
}
|
||||
+182
-175
@@ -17,10 +17,12 @@ import SwiftUI
|
||||
///
|
||||
/// ### The three interactions it hosts
|
||||
///
|
||||
/// - **Lane resize** — the right-edge grab strip (above).
|
||||
/// - **Lane reorder** — the whole title bar is the drag surface (`LaneReorderSession`,
|
||||
/// `LaneReorderMath`); the travelling lane rides above its siblings while they show the would-be
|
||||
/// order.
|
||||
/// - **Lane resize** — the right-edge grab strip (above). Deliberately *not* a drag session
|
||||
/// (DRAG-REORDER.md § Adjacent interaction).
|
||||
/// - **Drag & drop** — cards, lanes and trash rows travel as **system drag sessions**, which is what
|
||||
/// crosses window boundaries, draws the copy badge and gives the full-size replica
|
||||
/// (`DragSession`, `BoardDrops.swift`, DRAG-REORDER.md). The strip owns the drop geometry
|
||||
/// registry and the strip-level drop target; the lanes own theirs.
|
||||
/// - **The rubber band** — a drag from any empty surface sweeps a selection (`MarqueeSession`,
|
||||
/// `MarqueeMath`); the strip owns the session and the target registry, and hands both down.
|
||||
/// - **The board's fixed grammar keys** (11-command-nexus.md ▸ Fixed grammar keys) — the four
|
||||
@@ -34,8 +36,8 @@ import SwiftUI
|
||||
///
|
||||
/// ### What is deliberately not here yet
|
||||
///
|
||||
/// The toolbar, search, and drag & drop's real machinery (multi-drag, cross-board locality, the
|
||||
/// shadow's hold rule) all belong to later milestone cards.
|
||||
/// The toolbar and search belong to later milestone cards; so do external Finder file drops, which
|
||||
/// join the very drop delegates this file already attaches (see `BoardDrops.swift`).
|
||||
struct BoardView: View {
|
||||
|
||||
let store: BoardStore
|
||||
@@ -62,11 +64,10 @@ struct BoardView: View {
|
||||
/// view does, which is the interaction's whole lifetime.
|
||||
@State private var resize = LaneResizeSession()
|
||||
|
||||
/// One reorder at a time, per window — same lifetime, same reasoning.
|
||||
@State private var reorder = LaneReorderSession()
|
||||
|
||||
/// One drag out of the trash at a time, per window — same lifetime again.
|
||||
@State private var trashDrag = TrashDragSession()
|
||||
/// Where this window's lanes draw their card grids and how tall their cards are — the measured
|
||||
/// half of a card drop's geometry, plus the strip's own frame (`LaneDropRegistry`). `@State` for
|
||||
/// the resize session's reason: one per window, living exactly as long as the window.
|
||||
@State private var laneDrops = LaneDropRegistry()
|
||||
|
||||
/// One rubber band at a time, per window (`MarqueeSession`).
|
||||
@State private var marquee = MarqueeSession()
|
||||
@@ -94,36 +95,15 @@ struct BoardView: View {
|
||||
private let spacing: CGFloat = 12
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { viewport in
|
||||
let lanes = liveLanes
|
||||
// During a resize session the standard is FROZEN at its drag-start value: the window is
|
||||
// animating mid-resize, so deriving the standard from the live viewport width would feed
|
||||
// that animation back into every lane and pulse the whole strip. The window is sized on
|
||||
// each tick so this frozen value equals what the viewport formula yields once the
|
||||
// session ends — the handoff is seamless (see `LaneResizeSession`).
|
||||
let standard = resize.isActive
|
||||
? resize.standard
|
||||
: LaneLayoutMath.standardWidth(
|
||||
stripWidth: viewport.size.width,
|
||||
// The trash's one fixed unit joins the division **only while shown**, which is
|
||||
// the whole of "Show/Hide Trash is a re-divide trigger" (03-board-ui.md § Trash):
|
||||
// the window is never touched, the existing width simply divides across one more
|
||||
// unit and every lane compresses — a lane add's behaviour, exactly.
|
||||
totalUnits: LaneLayoutMath.totalUnits(of: lanes, trashUnits: isTrashVisible ? 1 : 0),
|
||||
gap: spacing)
|
||||
// The drag's drop proposal, computed once because two things read it: the order the
|
||||
// strip shows, and the key its reflow animates on. Recomputed on every render, so a
|
||||
// foreign reload mid-drag simply moves the zones (rule 1 of 04-interactions.md ▸ Drag
|
||||
// and drop's re-grounding trio).
|
||||
let move = proposal(among: lanes, standard: standard)
|
||||
// The lanes in the order the strip should *show* them: their snapshot order at rest, and
|
||||
// the drag's would-be order while a reorder is in flight — which is how the siblings
|
||||
// reflow to make room. A drag whose lane has vanished from the snapshot proposes nothing
|
||||
// and shows the plain order; its release then cancels ("an emptied drag cancels itself").
|
||||
let shown = move.map { LaneReorderMath.reordered(lanes, from: $0.from, to: $0.to) } ?? lanes
|
||||
GeometryReader { _ in
|
||||
// The strip's slots: the lanes the strip should *show*, with the drag's N contiguous
|
||||
// shadows opened at the proposal. Recomputed on every render, so a foreign reload
|
||||
// mid-drag simply moves the zones (rule 1 of 04-interactions.md ▸ Drag and drop's
|
||||
// re-grounding trio) — nothing about a drag is cached across a snapshot.
|
||||
let slots = stripSlots
|
||||
ZStack(alignment: .topLeading) {
|
||||
backdrop
|
||||
laneStrip(shown, standard: standard, move: move)
|
||||
laneStrip(slots)
|
||||
}
|
||||
.padding(spacing)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
@@ -132,14 +112,38 @@ struct BoardView: View {
|
||||
// marquee in the animation-free-by-construction list ("1:1 cursor following — animating
|
||||
// input echo would be lag").
|
||||
.overlay(alignment: .topLeading) { marqueeBand }
|
||||
// The space a drop out of the trash is resolved in — see `BoardView.stripSpace`. It goes
|
||||
// on the padded container so x = 0 is the strip's leading edge with the outer margin
|
||||
// included, which is the origin `LaneLayoutMath`'s arithmetic assumes. Every marquee
|
||||
// coordinate — the band's own drag samples and each registered item frame — is measured
|
||||
// here too, so nothing ever converts between spaces.
|
||||
// The space the marquee is resolved in — see `BoardView.stripSpace`. It goes on the
|
||||
// padded container so x = 0 is the strip's leading edge with the outer margin included,
|
||||
// which is the origin `LaneLayoutMath`'s arithmetic assumes. Every marquee coordinate —
|
||||
// the band's own drag samples and each registered item frame — is measured here too, so
|
||||
// nothing ever converts between spaces.
|
||||
.coordinateSpace(.named(Self.stripSpace))
|
||||
// The same rectangle in the window's *global* space, which is where the physical cursor
|
||||
// lands once converted (`BoardDropContext.globalCursor`). Written into the registry
|
||||
// rather than into `@State` so the drop delegates read it live at event time rather than
|
||||
// as of the last body evaluation.
|
||||
.onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { laneDrops.stripFrame = $0 }
|
||||
// **The strip's drop target** — the backdrop, the gaps, the outer margin, and the trash
|
||||
// column's footprint, which is never a landing spot of its own (04-interactions.md ▸ The
|
||||
// trash) and so simply falls through to here. It accepts *both* board types and routes
|
||||
// internally, because single-target dispatch has no fall-through (DRAG-REORDER.md).
|
||||
.onDrop(of: boardDragTypes, delegate: StripDropDelegate(context: dropContext))
|
||||
}
|
||||
.background(boardBackground)
|
||||
// The window-level fallback, *behind* the specific targets: a release over any in-window
|
||||
// region they don't cover commits the current proposal instead of leaking the session into a
|
||||
// cancel-snapback — the drop lands where the shadows show, which is what the shadows promise.
|
||||
.background {
|
||||
Color.clear
|
||||
.onDrop(of: boardDragTypes, delegate: BoardFallbackDropDelegate(context: dropContext))
|
||||
}
|
||||
// **The committed-overlay hold's hand-off** (DRAG-REORDER.md § The committed-overlay hold):
|
||||
// the overlay stands in for an arrangement that is on disk but not yet in the snapshot, and
|
||||
// discards itself the moment a snapshot lands — because holding a moment longer would draw
|
||||
// the arrangement twice.
|
||||
.onChange(of: store.snapshotGeneration) { _, generation in
|
||||
appModel.dragSession.handOff(root: store.rootURL, generation: generation)
|
||||
}
|
||||
.trashPurgeAlert(store: store, confirmations: confirmations)
|
||||
// The board's own anchor for the Style… popover — the surface a board-targeted session hangs
|
||||
// off, since the board has no item to attach to (`styleEditorPresentation`'s `nil` anchor).
|
||||
@@ -174,6 +178,19 @@ struct BoardView: View {
|
||||
// forbids outright (04 ▸ Configurable bindings). A focused text field consumes it first, so
|
||||
// ⌘A inside an inline editor stays text selection with no guard needed here.
|
||||
.onCommand(#selector(NSText.selectAll(_:))) { store.selectAll() }
|
||||
// **Belt** for a session SwiftUI does report ending: immediate cleanup, gated on the physical
|
||||
// mouse button being up. A finished session's phase events can arrive *after* the user has
|
||||
// started the next drag, and an ungated handler would wipe the new session's state — no
|
||||
// shadow, drop dead. `DragSession`'s watchdog is the braces (see `armWatchdog`), and it is
|
||||
// what makes refusing here free.
|
||||
.onDragSessionUpdated { session in
|
||||
switch session.phase {
|
||||
case .ended, .dataTransferCompleted:
|
||||
MainActor.assumeIsolated { appModel.dragSession.endIfButtonReleased() }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The strip's layers
|
||||
@@ -194,28 +211,38 @@ struct BoardView: View {
|
||||
.simultaneousGesture(marqueeControl.gesture(side: .live))
|
||||
}
|
||||
|
||||
/// The lanes themselves, plus the trash column when it is shown.
|
||||
/// The lanes and the drag's shadows, plus the trash column when it is shown.
|
||||
@ViewBuilder
|
||||
private func laneStrip(_ shown: [Lane], standard: CGFloat, move: (from: Int, to: Int)?) -> some View {
|
||||
private func laneStrip(_ slots: [StripSlot]) -> some View {
|
||||
let standard = standardWidth
|
||||
HStack(alignment: .top, spacing: spacing) {
|
||||
ForEach(Array(shown.enumerated()), id: \.element.id) { position, lane in
|
||||
laneSlot(lane, at: position, among: shown, standard: standard)
|
||||
// "Appear/disappear is scale + fade … lanes ~0.9" (03-board-ui.md § Motion).
|
||||
// A create, a delete and a Put Back all reach the strip as a lane arriving in
|
||||
// or leaving this `ForEach`; whether that *performs* is decided upstream, at
|
||||
// the reload that carried it (`Motion.reloadAnimates`) — a transition with no
|
||||
// animated transaction around it is simply an appearance.
|
||||
.transition(Motion.laneTransition(reduced: reduceMotion))
|
||||
ForEach(slots) { slot in
|
||||
switch slot {
|
||||
case let .lane(lane):
|
||||
laneSlot(lane, standard: standard)
|
||||
// "Appear/disappear is scale + fade … lanes ~0.9" (03-board-ui.md § Motion).
|
||||
// A create, a delete and a Put Back all reach the strip as a lane arriving in
|
||||
// or leaving this `ForEach`; whether that *performs* is decided upstream, at
|
||||
// the reload that carried it (`Motion.reloadAnimates`) — a transition with no
|
||||
// animated transaction around it is simply an appearance.
|
||||
.transition(Motion.laneTransition(reduced: reduceMotion))
|
||||
case let .shadow(_, units):
|
||||
// One of the drag's N contiguous shadows, at the exact width the arriving lane
|
||||
// will occupy — its units measured against *this* strip's standard, which is
|
||||
// what makes the drop land precisely where the shadow shows.
|
||||
DragShadow()
|
||||
.frame(width: LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing))
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
if isTrashVisible {
|
||||
// Trailing, always — the quasi-lane has no position of its own to lose, which is
|
||||
// also why it never appears in the reorder proposal's inputs (those are built
|
||||
// from `liveLanes`).
|
||||
// also why it never appears in the drop proposal's inputs (those are built from
|
||||
// `liveLanes`) and why the terminal slot clamps in front of it.
|
||||
TrashLaneView(
|
||||
store: store,
|
||||
confirmations: confirmations,
|
||||
drag: TrashRowDrag { x in laneUnder(x: x, standard: standard) },
|
||||
dragSession: trashDrag,
|
||||
drops: dropContext,
|
||||
marquee: marqueeControl
|
||||
)
|
||||
.frame(width: LaneLayoutMath.slotWidth(units: 1, standard: standard, gap: spacing))
|
||||
@@ -229,15 +256,14 @@ struct BoardView: View {
|
||||
}
|
||||
// The drag's reflow-to-make-room, keyed on the **drop proposal** and nothing else
|
||||
// (03-board-ui.md § Motion: transactions are keyed narrowly, "on the drag's drop
|
||||
// proposal … never on broad state"). The travelling lane's own offset changes on every
|
||||
// pointer sample and none of those samples touch this value, so the replica keeps
|
||||
// tracking the cursor 1:1 — which is the same bullet's other half. At the instant the
|
||||
// proposal ticks, the lane's slot and its offset move by equal and opposite amounts, so
|
||||
// animating both under one curve is what keeps it pinned under the cursor.
|
||||
// proposal … never on broad state"). Narrowed further to the *strip's* proposal: a card
|
||||
// session moving its shadow inside a lane must not re-time the whole strip. The replica's
|
||||
// own tracking is the system drag image's and touches nothing here, which is the same
|
||||
// bullet's other half — animating input echo would be lag.
|
||||
//
|
||||
// It stays on the `HStack` rather than moving out to the `ZStack`, so the marquee band
|
||||
// drawn beside it is never inside an animated transaction (03 § Motion again).
|
||||
.animation(Motion.dragReflow(reduced: reduceMotion), value: move?.to)
|
||||
.animation(Motion.dragReflow(reduced: reduceMotion), value: stripProposal)
|
||||
}
|
||||
|
||||
/// The rubber band itself: a translucent accent fill with a hairline border, in strip
|
||||
@@ -303,22 +329,19 @@ struct BoardView: View {
|
||||
/// The outer frame is always the snapped slot width, so the `HStack` lays the other lanes out
|
||||
/// off the tidy snapped layout regardless of the live overflow.
|
||||
///
|
||||
/// While this lane is being **reordered** the slot instead keeps its resting size and travels:
|
||||
/// the offset is the gap between where the pointer has carried it and where it would rest under
|
||||
/// the current proposal, so it tracks the cursor 1:1 while its siblings sit in the would-be
|
||||
/// order beneath it (`zIndex(2)`, above even a resize).
|
||||
/// A lane being **dragged** is simply absent from the strip: it is lifted out of the resting
|
||||
/// layout at pickup and stays out until release, whatever the effective operation is
|
||||
/// (DRAG-REORDER.md § Resting-layout zones), while the system drag session carries its replica.
|
||||
@ViewBuilder
|
||||
private func laneSlot(_ lane: Lane, at position: Int, among shown: [Lane], standard: CGFloat) -> some View {
|
||||
private func laneSlot(_ lane: Lane, standard: CGFloat) -> some View {
|
||||
let resizing = resize.isResizing(lane.id)
|
||||
let dragging = reorder.isDragging(lane.id)
|
||||
let units = resizing ? resize.units : LaneLayoutMath.displayUnits(of: lane)
|
||||
let slotWidth = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing)
|
||||
ZStack(alignment: .topLeading) {
|
||||
if resizing {
|
||||
LaneResizeShadow()
|
||||
DragShadow(dashed: false)
|
||||
.frame(width: slotWidth)
|
||||
.frame(maxHeight: .infinity)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
// Interior columns follow the SNAPPED unit count while this lane is being resized — a
|
||||
// column count is integral, so it tracks k (which ticks and animates), not the live
|
||||
@@ -329,27 +352,15 @@ struct BoardView: View {
|
||||
store: store,
|
||||
lane: lane,
|
||||
columns: units,
|
||||
reorder: reorder,
|
||||
headerDrag: headerDrag(at: position, among: shown, standard: standard),
|
||||
slotWidth: slotWidth,
|
||||
drops: dropContext,
|
||||
marquee: marqueeControl,
|
||||
openCard: openCard
|
||||
)
|
||||
.frame(width: resizing ? resize.liveWidth : slotWidth, alignment: .leading)
|
||||
}
|
||||
// The drop highlight for a drag out of the trash: the lane the pointer is currently over.
|
||||
// Feedback lives on the *target* rather than on a travelling replica, because the replica —
|
||||
// its lift, its settle, the copy/move badge — is m5's drag card (03-board-ui.md § Motion).
|
||||
.overlay {
|
||||
if trashDrag.isTarget(lane.id) {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.strokeBorder(Color.accentColor, lineWidth: 2)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.frame(width: slotWidth, alignment: .topLeading)
|
||||
.offset(x: dragging ? travelOffset(at: position, among: shown, standard: standard) : 0)
|
||||
.opacity(dragging ? 0.9 : 1)
|
||||
.zIndex(dragging ? 2 : (resizing ? 1 : 0))
|
||||
.zIndex(resizing ? 1 : 0)
|
||||
.overlay(alignment: .trailing) {
|
||||
LaneResizeHandle(
|
||||
store: store,
|
||||
@@ -365,9 +376,10 @@ struct BoardView: View {
|
||||
// resizes the *window* on the way, so a refused commit would leave the window grown
|
||||
// around a lane that snapped back — and the lock's row is already saying why nothing
|
||||
// can be written. The focused-editor rule closes it too, like every board command, and
|
||||
// so does a reorder in flight: two drags mutating one strip layout is not a state this
|
||||
// view has a meaning for.
|
||||
.disabled(store.isReadOnly || store.isEditingInline || reorder.isActive)
|
||||
// so does a drag session in flight — the edge drag "refuses to start while a card/lane
|
||||
// session is in flight" (DRAG-REORDER.md § Adjacent interaction): two gestures mutating
|
||||
// one strip layout is not a state this view has a meaning for.
|
||||
.disabled(store.isReadOnly || store.isEditingInline || appModel.dragSession.isActive)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,90 +399,99 @@ struct BoardView: View {
|
||||
store.transient.isTrashVisible
|
||||
}
|
||||
|
||||
/// The live lane under `x` in strip coordinates, or `nil` — the strip's half of drag-to-restore.
|
||||
///
|
||||
/// Re-derived against `liveLanes` at gesture time rather than captured at drag start, which is
|
||||
/// 04-interactions.md ▸ Drag and drop's re-grounding rule: a foreign reload that adds or
|
||||
/// tombstones a lane mid-drag just moves the zones, and the next proposal targets the board as it
|
||||
/// now is. A tombstoned lane is never a drop target because it is never in this list.
|
||||
private func laneUnder(x: CGFloat, standard: CGFloat) -> ItemID? {
|
||||
let lanes = liveLanes
|
||||
guard let index = LaneLayoutMath.laneIndex(
|
||||
atX: x,
|
||||
unitCounts: unitCounts(of: lanes),
|
||||
standard: standard,
|
||||
gap: spacing
|
||||
) else { return nil }
|
||||
return lanes.indices.contains(index) ? lanes[index].id : nil
|
||||
}
|
||||
// MARK: - The drag
|
||||
|
||||
// MARK: - Reorder
|
||||
|
||||
/// Where the dragged lane sits in `lanes` and where it would land — `nil` when no reorder is in
|
||||
/// flight, or when the lane it is carrying is no longer on the board.
|
||||
/// What each of this window's drop targets — and its lanes' autoscroll drivers — is handed.
|
||||
///
|
||||
/// Called **once** per render, because a proposal is two things at once and they must be the
|
||||
/// same answer: the order the strip shows, and the narrow key its reflow animates on (see
|
||||
/// `body`). It is asked again at release, against the snapshot as it is by then
|
||||
/// (`commitReorder`).
|
||||
private func proposal(among lanes: [Lane], standard: CGFloat) -> (from: Int, to: Int)? {
|
||||
guard let id = reorder.laneID, let from = lanes.firstIndex(where: { $0.id == id }) else { return nil }
|
||||
let to = LaneReorderMath.proposedIndex(
|
||||
unitCounts: unitCounts(of: lanes),
|
||||
draggedIndex: from,
|
||||
dragCentreX: reorder.centre,
|
||||
standard: standard,
|
||||
gap: spacing
|
||||
/// Every geometric input is a **closure**, read at event time (`BoardDropContext`): a captured
|
||||
/// snapshot of the strip's frame or its standard width goes stale the moment the layout animates,
|
||||
/// and two delegates holding different snapshots would flap the proposal between them.
|
||||
private var dropContext: BoardDropContext {
|
||||
BoardDropContext(
|
||||
store: store,
|
||||
session: appModel.dragSession,
|
||||
registry: laneDrops,
|
||||
gap: spacing,
|
||||
window: window,
|
||||
stripFrame: { laneDrops.stripFrame },
|
||||
standard: { standardWidth }
|
||||
)
|
||||
return (from, to)
|
||||
}
|
||||
|
||||
/// How far the travelling lane is drawn from the slot it would rest in — the pointer's position
|
||||
/// minus the proposal's. Zero at the instant a tick lands, growing again as the pointer moves
|
||||
/// on, which is what makes the replica read as *held* rather than as snapping.
|
||||
private func travelOffset(at position: Int, among shown: [Lane], standard: CGFloat) -> CGFloat {
|
||||
reorder.centre - LaneReorderMath.centre(
|
||||
ofLaneAt: position,
|
||||
unitCounts: unitCounts(of: shown),
|
||||
standard: standard,
|
||||
/// The strip's 1× lane width.
|
||||
///
|
||||
/// During a resize session the standard is **frozen** at its drag-start value: the window is
|
||||
/// animating mid-resize, so deriving the standard from the live width would feed that animation
|
||||
/// back into every lane and pulse the whole strip. The window is sized on each tick so this frozen
|
||||
/// value equals what the formula yields once the session ends — the handoff is seamless (see
|
||||
/// `LaneResizeSession`).
|
||||
///
|
||||
/// Otherwise it is the ordinary division, over three contributions:
|
||||
///
|
||||
/// - the **live lanes**' units. A lane in flight is still a lane on the board, so a within-board
|
||||
/// drag does *not* re-divide the strip: "the shadow occupies its units" (DRAG-REORDER.md § The
|
||||
/// lane strip's resting layout is arithmetic). Recomputing at pickup and again at release is
|
||||
/// exactly the motion-feeds-back-into-logic failure the model exists to avoid.
|
||||
/// - the **trash's** one fixed unit, *only while shown* — the whole of "Show/Hide Trash is a
|
||||
/// re-divide trigger" (03-board-ui.md § Trash): the window is never touched, the existing width
|
||||
/// simply divides across one more unit and every lane compresses.
|
||||
/// - a **cross-board lane arrival**'s units while its shadow hovers here, by the same rule read
|
||||
/// from the destination's side: the shadow occupies its units, and the strip has to make room
|
||||
/// for them or the shadow would be drawn at a width the lane will not have.
|
||||
private var standardWidth: CGFloat {
|
||||
if resize.isActive { return resize.standard }
|
||||
var units = LaneLayoutMath.totalUnits(of: liveLanes, trashUnits: isTrashVisible ? 1 : 0)
|
||||
units += arrivingLaneUnits
|
||||
return LaneLayoutMath.standardWidth(
|
||||
stripWidth: laneDrops.stripFrame.width,
|
||||
totalUnits: units,
|
||||
gap: spacing
|
||||
)
|
||||
}
|
||||
|
||||
/// The strip's half of a lane header's drag: where the lane rests now, and what a release means.
|
||||
private func headerDrag(at position: Int, among shown: [Lane], standard: CGFloat) -> LaneHeaderDrag {
|
||||
LaneHeaderDrag(
|
||||
startCentre: {
|
||||
LaneReorderMath.centre(
|
||||
ofLaneAt: position,
|
||||
unitCounts: unitCounts(of: shown),
|
||||
standard: standard,
|
||||
gap: spacing
|
||||
)
|
||||
},
|
||||
commit: { commitReorder(standard: standard) }
|
||||
)
|
||||
/// The units a cross-board lane run would add to this strip while its shadow is proposed here;
|
||||
/// zero for a within-board drag, whose lanes are already counted.
|
||||
private var arrivingLaneUnits: Int {
|
||||
let session = appModel.dragSession
|
||||
guard session.isDraggingLanes,
|
||||
session.stripProposal(onBoardRooted: store.rootURL) != nil,
|
||||
let source = session.sourceRoot,
|
||||
!DragLocality.isSameBoard(source, store.rootURL)
|
||||
else { return 0 }
|
||||
return session.laneUnits.reduce(0, +)
|
||||
}
|
||||
|
||||
/// Releases the drag: re-derive the proposal against the snapshot **as it is now** and write it.
|
||||
///
|
||||
/// Re-deriving rather than trusting the last rendered proposal is 04-interactions.md ▸ Drag and
|
||||
/// drop's re-grounding rule at its most consequential moment: a reload that landed between the
|
||||
/// last render and the release must not be written over. A lane that vanished in that window
|
||||
/// yields no proposal and the release simply cancels — "release with no valid proposal cancels;
|
||||
/// items return, nothing is written".
|
||||
///
|
||||
/// `BoardStore.moveLane` owns the rest, the unchanged-index no-op included.
|
||||
private func commitReorder(standard: CGFloat) {
|
||||
defer { reorder.end() }
|
||||
guard let id = reorder.laneID,
|
||||
let (_, to) = proposal(among: liveLanes, standard: standard)
|
||||
else { return }
|
||||
store.moveLane(id, toIndex: to)
|
||||
/// The strip's current lane-drop proposal — the reflow's narrow animation key, and where the
|
||||
/// shadow run opens.
|
||||
private var stripProposal: Int? {
|
||||
appModel.dragSession.stripProposal(onBoardRooted: store.rootURL)
|
||||
}
|
||||
|
||||
private func unitCounts(of lanes: [Lane]) -> [Int] {
|
||||
lanes.map { LaneLayoutMath.displayUnits(of: $0) }
|
||||
/// One position in the strip: a lane, or one of the drag's N contiguous shadows.
|
||||
private enum StripSlot: Identifiable {
|
||||
case lane(Lane)
|
||||
case shadow(index: Int, units: Int)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .lane(lane): "lane:\(lane.id.rawValue)"
|
||||
// Constant per position, so a shadow run keeps its identity as the proposal slides and
|
||||
// the run animates as a move rather than blinking out and back in.
|
||||
case let .shadow(index, _): "shadow:\(index)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the strip lays out: the resting lanes — the dragged run lifted out, whatever the
|
||||
/// effective operation is — with the shadows opened at the proposal.
|
||||
private var stripSlots: [StripSlot] {
|
||||
let session = appModel.dragSession
|
||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
||||
var slots = liveLanes.filter { !hidden.contains($0.id) }.map(StripSlot.lane)
|
||||
guard let index = stripProposal else { return slots }
|
||||
let shadows = session.laneUnits.enumerated().map { StripSlot.shadow(index: $0.offset, units: $0.element) }
|
||||
slots.insert(contentsOf: shadows, at: min(max(0, index), slots.count))
|
||||
return slots
|
||||
}
|
||||
|
||||
// MARK: - Grammar keys
|
||||
@@ -866,17 +887,3 @@ struct BoardView: View {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Resize shadow
|
||||
|
||||
/// The resting footprint a lane snaps back to, drawn behind the live lane during a resize.
|
||||
///
|
||||
/// Minimal on purpose: 03-board-ui.md's placeholder/drag vocabulary lands with the drag milestone,
|
||||
/// and this is the same shape that card and lane drops will want. Kept here rather than invented
|
||||
/// twice.
|
||||
private struct LaneResizeShadow: View {
|
||||
var body: some View {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(.quaternary.opacity(0.5))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import AppKit
|
||||
import QuartzCore
|
||||
import SwiftUI
|
||||
|
||||
/// Drives edge-autoscroll for one lane's scroll view across one drag session — the *driver* half of
|
||||
/// DRAG-REORDER.md § Edge autoscroll, whose geometry is `DragAutoScrollMath`.
|
||||
///
|
||||
/// Three constraints shape it, and each is a rule from that section:
|
||||
///
|
||||
/// - **The pointer is the physical mouse.** Partly for the general animation-proof reason, but mostly
|
||||
/// because drop callbacks only arrive while the mouse *moves*, and holding still against an edge is
|
||||
/// exactly the gesture that must keep scrolling. A ticking task plus `NSEvent.mouseLocation` needs
|
||||
/// no events at all.
|
||||
/// - **Every scroll step re-resolves the proposal.** The cursor is stationary in the lane's space
|
||||
/// while the *content* moves under it, so without this the shadow would freeze at whatever slot the
|
||||
/// last mouse movement proposed and the drop would land there. `didScroll` is that callback, and it
|
||||
/// goes through the same `BoardDropContext.retargetCards(inLane:)` the lane's drop delegate uses —
|
||||
/// one shared retarget, so the two can never disagree.
|
||||
/// - **Termination is structural.** The driver is owned by a `.task(id:)` keyed on the session, so it
|
||||
/// is cancelled the moment the session ends — and `DragSession`'s watchdog guarantees that flag
|
||||
/// clears no matter how the drag finished.
|
||||
@MainActor
|
||||
final class DragAutoScroller {
|
||||
|
||||
/// A view living inside the scroll view's *content*: its `enclosingScrollView` is the scroller to
|
||||
/// drive, and its window converts the physical cursor. Weak — the view belongs to the hierarchy.
|
||||
fileprivate weak var anchor: NSView?
|
||||
|
||||
/// Invoked after every scroll step. Re-resolves the drop proposal; see the type's note.
|
||||
fileprivate var didScroll: (() -> Void)?
|
||||
|
||||
private var lastTick: CFTimeInterval?
|
||||
|
||||
nonisolated init() {}
|
||||
|
||||
/// Ticks at display rate until the owning task is cancelled.
|
||||
func run() async {
|
||||
lastTick = nil
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .milliseconds(16))
|
||||
guard !Task.isCancelled else { break }
|
||||
step()
|
||||
}
|
||||
lastTick = nil
|
||||
}
|
||||
|
||||
private func step() {
|
||||
let now = CACurrentMediaTime()
|
||||
let elapsed = min(max(now - (lastTick ?? now), 0), 0.05)
|
||||
lastTick = now
|
||||
guard elapsed > 0,
|
||||
let anchor,
|
||||
let window = anchor.window,
|
||||
let scrollView = anchor.enclosingScrollView
|
||||
else { return }
|
||||
|
||||
let clip = scrollView.contentView
|
||||
let visible = clip.bounds
|
||||
guard visible.width > 0, visible.height > 0 else { return }
|
||||
|
||||
// Physical cursor → window → the clip view's (scrolled) coordinates, then relative to the
|
||||
// visible area's top-left corner, which is the space `DragAutoScrollMath` is written in.
|
||||
let inWindow = window.convertPoint(fromScreen: NSEvent.mouseLocation)
|
||||
let inClip = clip.convert(inWindow, from: nil)
|
||||
let pointer = CGPoint(x: inClip.x - visible.minX, y: inClip.y - visible.minY)
|
||||
guard DragAutoScrollMath.engagementRect(viewport: visible.size).contains(pointer) else { return }
|
||||
|
||||
let velocity = DragAutoScrollMath.velocity(pointer: pointer, viewport: visible.size)
|
||||
guard velocity.dx != 0 || velocity.dy != 0 else { return }
|
||||
|
||||
let document = scrollView.documentView?.frame ?? .zero
|
||||
var proposed = visible
|
||||
proposed.origin.x = DragAutoScrollMath.nextOffset(
|
||||
current: visible.minX, velocity: velocity.dx, elapsed: CGFloat(elapsed),
|
||||
minOffset: document.minX, maxOffset: document.maxX - visible.width)
|
||||
proposed.origin.y = DragAutoScrollMath.nextOffset(
|
||||
current: visible.minY, velocity: velocity.dy, elapsed: CGFloat(elapsed),
|
||||
minOffset: document.minY, maxOffset: document.maxY - visible.height)
|
||||
// The clip view has the final say (content insets, magnification).
|
||||
let target = clip.constrainBoundsRect(proposed).origin
|
||||
guard abs(target.x - visible.minX) > 0.01 || abs(target.y - visible.minY) > 0.01 else { return }
|
||||
|
||||
clip.scroll(to: target)
|
||||
scrollView.reflectScrolledClipView(clip)
|
||||
didScroll?()
|
||||
}
|
||||
}
|
||||
|
||||
/// Binds a `DragAutoScroller` to the scroll view it should drive.
|
||||
///
|
||||
/// **Must be placed inside the scroll view's content** (as its `.background`), so
|
||||
/// `enclosingScrollView` resolves; a background on the `ScrollView` itself sits outside the clip view
|
||||
/// and would find nothing.
|
||||
struct DragAutoScrollAnchor: NSViewRepresentable {
|
||||
|
||||
let scroller: DragAutoScroller
|
||||
|
||||
/// Refreshed on every view update, so the callback always closes over the current context rather
|
||||
/// than the one the session started with.
|
||||
let didScroll: () -> Void
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = NSView()
|
||||
bind(view)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ view: NSView, context: Context) {
|
||||
bind(view)
|
||||
}
|
||||
|
||||
private func bind(_ view: NSView) {
|
||||
scroller.anchor = view
|
||||
scroller.didScroll = didScroll
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
// MARK: - The drag types
|
||||
|
||||
extension UTType {
|
||||
|
||||
/// A drag carrying board **cards** — live card faces or trash rows (04-interactions.md ▸ Drag
|
||||
/// and drop, ▸ The trash). Declared as an exported type in `Info.plist`, because a system drag
|
||||
/// session is what crosses window boundaries, shows the copy badge, and gives the full-size
|
||||
/// replica preview (DRAG-REORDER.md § Cross-board sessions).
|
||||
static let laneworkCards = UTType(exportedAs: "dev.rzen.indie.kanban.cards")
|
||||
|
||||
/// A drag carrying board **lanes** — the strip's own reorder and the cross-board lane transfer.
|
||||
static let laneworkLanes = UTType(exportedAs: "dev.rzen.indie.kanban.lanes")
|
||||
}
|
||||
|
||||
// MARK: - What a drag carries
|
||||
|
||||
/// Which of the board's two levels a drag session carries. The selection is homogeneous by kind
|
||||
/// (04-interactions.md § Selection), so a session is one or the other and never both.
|
||||
enum DragKind: String, Codable, Sendable, Equatable {
|
||||
case cards
|
||||
case lanes
|
||||
|
||||
var type: UTType {
|
||||
switch self {
|
||||
case .cards: .laneworkCards
|
||||
case .lanes: .laneworkLanes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The JSON a drag session puts on the pasteboard.
|
||||
///
|
||||
/// **Same-app transfer is the only real consumer.** Both boards are open in this app, so the hover
|
||||
/// path reads identity from `DragSession` — captured synchronously at drag start — and never decodes
|
||||
/// anything mid-flight; the payload is the formal drop data, and what makes the session a *system*
|
||||
/// session at all (which is what crosses windows and draws the badge). It carries folder URLs
|
||||
/// because that is what the destination store's commits take (`receiveCards`, `receiveLanes`), and
|
||||
/// the source board root beside them because locality — the Finder volume model — is a comparison of
|
||||
/// roots (04-interactions.md ▸ Drag and drop).
|
||||
///
|
||||
/// A **plain-text secondary representation** (the dragged titles, newline-joined) rides along so a
|
||||
/// stray drop into a text editor does something sane rather than nothing.
|
||||
struct DragPayload: Codable, Sendable, Equatable {
|
||||
|
||||
/// Which side of the live/tombstoned boundary the drag started on — a trash row's drag is a card
|
||||
/// drag from the trashed side, and 04-interactions.md ▸ The trash gives it its own rules
|
||||
/// (restore within the board, copy-out across boards).
|
||||
enum Side: String, Codable, Sendable, Equatable {
|
||||
case live
|
||||
case trashed
|
||||
|
||||
init(_ liveness: Liveness) {
|
||||
self = liveness == .live ? .live : .trashed
|
||||
}
|
||||
|
||||
var liveness: Liveness { self == .live ? .live : .trashed }
|
||||
}
|
||||
|
||||
/// One dragged item: its UUID, its folder on disk, and its title for the text representation.
|
||||
struct Item: Codable, Sendable, Equatable {
|
||||
var id: String
|
||||
var folder: String
|
||||
var title: String?
|
||||
}
|
||||
|
||||
/// The **source** board's root folder — the left-hand side of the locality comparison.
|
||||
var boardRoot: String
|
||||
|
||||
var kind: DragKind
|
||||
var side: Side
|
||||
|
||||
/// The dragged items **in flatten order** — "lane `order` first, then card `order`"
|
||||
/// (04-interactions.md ▸ Drag and drop). The drop commits trust this order rather than
|
||||
/// re-deriving it, because a cross-board destination has no flatten order for items it does not
|
||||
/// hold.
|
||||
var items: [Item]
|
||||
|
||||
var ids: [ItemID] { items.map { ItemID(rawValue: $0.id) } }
|
||||
|
||||
var folders: [URL] { items.map { URL(fileURLWithPath: $0.folder, isDirectory: true) } }
|
||||
|
||||
var rootURL: URL { URL(fileURLWithPath: boardRoot, isDirectory: true) }
|
||||
|
||||
/// The secondary representation: one title per line, untitled items rendered as they are on the
|
||||
/// board (03-board-ui.md § Card face — "Untitled" is a rendering, never a value).
|
||||
var plainText: String {
|
||||
items.map { $0.title ?? "Untitled" }.joined(separator: "\n")
|
||||
}
|
||||
|
||||
// MARK: Coding
|
||||
|
||||
func encoded() -> Data? {
|
||||
try? JSONEncoder().encode(self)
|
||||
}
|
||||
|
||||
init(boardRoot: URL, kind: DragKind, side: Liveness, items: [Item]) {
|
||||
self.boardRoot = boardRoot.path
|
||||
self.kind = kind
|
||||
self.side = Side(side)
|
||||
self.items = items
|
||||
}
|
||||
|
||||
init?(data: Data) {
|
||||
guard let decoded = try? JSONDecoder().decode(DragPayload.self, from: data) else { return nil }
|
||||
self = decoded
|
||||
}
|
||||
|
||||
/// The item provider a `.onDrag` hands back: the JSON under this session's own type, plus the
|
||||
/// plain-text fallback.
|
||||
///
|
||||
/// The custom type is registered `.ownProcess` deliberately — the payload names folders inside
|
||||
/// the user's boards, and no other app has any business reading it; the *text* is the
|
||||
/// representation other apps get.
|
||||
func itemProvider() -> NSItemProvider {
|
||||
let provider = NSItemProvider()
|
||||
if let data = encoded() {
|
||||
provider.registerDataRepresentation(
|
||||
forTypeIdentifier: kind.type.identifier,
|
||||
visibility: .ownProcess
|
||||
) { completion in
|
||||
completion(data, nil)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
let text = Data(plainText.utf8)
|
||||
provider.registerDataRepresentation(
|
||||
forTypeIdentifier: UTType.utf8PlainText.identifier,
|
||||
visibility: .all
|
||||
) { completion in
|
||||
completion(text, nil)
|
||||
return nil
|
||||
}
|
||||
return provider
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The **shadow placeholder** — the outline occupying an item's proposed landing spot
|
||||
/// (DRAG-REORDER.md § The pieces).
|
||||
///
|
||||
/// One shape for both the drag's shadows and the lane resize's resting footprint, because they are
|
||||
/// the same thing said twice: "here is the space this item occupies in the layout". They differ only
|
||||
/// in whether the space is a *proposal* — the drag's is dashed, because a dashed outline is what
|
||||
/// 03-board-ui.md's placeholder vocabulary asks for and what makes "the drop lands exactly here"
|
||||
/// legible; the resize's is a plain fill, because the lane is already there.
|
||||
///
|
||||
/// **Hit-transparent, always.** The strip's own drop target has to stay live beneath the shadows
|
||||
/// (DRAG-REORDER.md § Single-target dispatch: "shadow placeholders are hit-transparent, so the strip
|
||||
/// target stays live beneath them"), and a shadow that swallowed the release would strand the drop.
|
||||
struct DragShadow: View {
|
||||
|
||||
/// Matched to the surface it stands in for — a lane's plate is 10, a card's is 8.
|
||||
var cornerRadius: CGFloat = 10
|
||||
|
||||
/// A drop proposal (dashed) or a resting footprint (plain).
|
||||
var dashed: Bool = true
|
||||
|
||||
var body: some View {
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(.quaternary.opacity(0.5))
|
||||
.overlay {
|
||||
if dashed {
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.strokeBorder(
|
||||
Color.accentColor.opacity(0.55),
|
||||
style: StrokeStyle(lineWidth: 1.5, dash: [6])
|
||||
)
|
||||
}
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// The count badge a multi-drag's replica wears — Finder's own affordance for "this many are coming
|
||||
/// with me" (04-interactions.md ▸ Drag and drop: "dragging any member of a multi-selection drags the
|
||||
/// whole selection").
|
||||
///
|
||||
/// Draws nothing for a single item, so every replica can carry it unconditionally.
|
||||
struct DragCountBadge: View {
|
||||
|
||||
let count: Int
|
||||
|
||||
var body: some View {
|
||||
if count > 1 {
|
||||
Text("\(count)")
|
||||
.font(.caption2.bold())
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(Circle().fill(Color.accentColor))
|
||||
.offset(x: 10, y: -10)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ enum DropSlotMath {
|
||||
///
|
||||
/// The strip's outer margin is one `gap`, so the first slot starts at `gap`; each lane is
|
||||
/// `LaneLayoutMath.slotWidth(units:standard:gap:)` wide and one `gap` follows it. Same
|
||||
/// arithmetic `LaneReorderMath.centre` walks, in range form.
|
||||
/// arithmetic `LaneLayoutMath.laneIndex` walks, in range form.
|
||||
///
|
||||
/// `unitCounts` is the **visible lanes minus the dragged run**. `standard` is *not* recomputed
|
||||
/// for that shorter list: it is a function of the board's unit total, and a lane in flight is
|
||||
|
||||
@@ -93,7 +93,7 @@ enum LaneLayoutMath {
|
||||
/// "a drop anywhere else is a no-op" (03-board-ui.md § Trash): a drop that does not land
|
||||
/// squarely on a live lane writes nothing rather than guessing at the nearest one.
|
||||
///
|
||||
/// Same analytic geometry as `LaneReorderMath.proposedIndex` — resting positions computed from
|
||||
/// Same analytic geometry as `DropSlotMath.laneExtents` — resting positions computed from
|
||||
/// the unit counts, never measured frames (03-board-ui.md § Motion, "motion never feeds back
|
||||
/// into logic").
|
||||
static func laneIndex(atX x: CGFloat, unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> Int? {
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import CoreGraphics
|
||||
|
||||
/// The lane-reorder drag's geometry, as pure arithmetic — no view, no session, no snapshot
|
||||
/// (`LaneReorderMathTests`). `LaneLayoutMath`'s sibling: that one owns the resting layout and the
|
||||
/// right-edge resize, this one owns "where would the lane land if I let go now".
|
||||
///
|
||||
/// **Geometry-based, so the proposal is stable rather than jittery** (04-interactions.md ▸ Drag and
|
||||
/// drop): the answer is a function of analytically computed resting positions and one pointer
|
||||
/// coordinate — never of measured mid-flight frames, which are garbage precisely during the reflow
|
||||
/// they trigger (03-board-ui.md § Motion, "Motion never feeds back into logic").
|
||||
///
|
||||
/// **Width-aware by construction.** The design asks for "no reflow until the cursor reaches where
|
||||
/// the dragged lane would actually land"; comparing against each remaining lane's *centre* is
|
||||
/// exactly that — a 3× lane's centre is three units along, so the drag has to travel most of that
|
||||
/// lane's width before the board proposes stepping past it, and a 1× lane yields quickly.
|
||||
///
|
||||
/// ### What this deliberately is not
|
||||
///
|
||||
/// The full drag model — the shadow's hold-until-a-new-candidate rule, multi-drag's N contiguous
|
||||
/// shadows, cross-board locality with its copy/move badge, and the mid-drag re-grounding rules — is
|
||||
/// **m5's drag card**, which replaces this file's callers with the real `DropSlot`
|
||||
/// (02-architecture.md § Layering ▸ Components). What is here is the within-board single-lane case
|
||||
/// and nothing else, deliberately small enough to be obviously correct.
|
||||
enum LaneReorderMath {
|
||||
|
||||
/// Where the dragged lane would land: an index into the ordered live lanes **with the dragged
|
||||
/// lane removed**, so the result is in `0...(unitCounts.count - 1)` and `draggedIndex` itself
|
||||
/// means "back where it started".
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - unitCounts: the ordered live lanes' display units (`LaneLayoutMath.displayUnits`), the
|
||||
/// board as it currently is — recomputed against each snapshot rather than frozen at drag
|
||||
/// start, so a foreign lane add or tombstone mid-drag just moves the zones and the next
|
||||
/// proposal targets the board as it now is (04 ▸ Drag and drop, rule 1).
|
||||
/// - draggedIndex: the dragged lane's position in `unitCounts`.
|
||||
/// - dragCentreX: the dragged lane's centre under the cursor, in strip coordinates (0 at the
|
||||
/// strip's leading edge, outer margin included).
|
||||
/// - standard: the 1× lane width (`LaneLayoutMath.standardWidth`).
|
||||
/// - gap: the inter-lane gap, which is also the strip's outer margin.
|
||||
///
|
||||
/// Out-of-range `draggedIndex` yields `0` rather than trapping: the lane vanished under the
|
||||
/// drag, and the caller's release-with-no-valid-proposal rule cancels anyway.
|
||||
static func proposedIndex(
|
||||
unitCounts: [Int],
|
||||
draggedIndex: Int,
|
||||
dragCentreX: CGFloat,
|
||||
standard: CGFloat,
|
||||
gap: CGFloat
|
||||
) -> Int {
|
||||
guard unitCounts.indices.contains(draggedIndex) else { return 0 }
|
||||
|
||||
var remaining = unitCounts
|
||||
remaining.remove(at: draggedIndex)
|
||||
|
||||
// The remaining lanes' resting centres, left to right, in the layout they would have with
|
||||
// the dragged lane gone — which is the layout the siblings are already showing.
|
||||
// Monotonically increasing, so "how many centres has the cursor passed" is both the answer
|
||||
// and the reason it never oscillates: one threshold per slot, crossed once.
|
||||
var index = 0
|
||||
var x = gap
|
||||
for units in remaining {
|
||||
let width = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap)
|
||||
guard dragCentreX > x + width / 2 else { break }
|
||||
index += 1
|
||||
x += width + gap
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
/// The resting centre of the lane at `index` in a strip of `unitCounts`, in the same strip
|
||||
/// coordinates `proposedIndex` reads.
|
||||
///
|
||||
/// Two callers, and they are the two halves of the drag: the gesture freezes this at drag start
|
||||
/// as the origin its translation is measured from (the *physical pointer* being the only live
|
||||
/// input — 03 § Motion), and the view offsets the travelling lane from the centre it would rest
|
||||
/// at under the current proposal, which is what makes the replica track the cursor while the
|
||||
/// siblings sit in their would-be order.
|
||||
static func centre(ofLaneAt index: Int, unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> CGFloat {
|
||||
var x = gap
|
||||
for (position, units) in unitCounts.enumerated() {
|
||||
let width = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap)
|
||||
if position == index { return x + width / 2 }
|
||||
x += width + gap
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
/// `unitCounts` (or any per-lane values) with the item at `from` moved to `to`, where `to` is
|
||||
/// counted **with the item already removed** — the ordering `proposedIndex` returns, applied.
|
||||
///
|
||||
/// Shared by the view (which reorders the lanes it lays out, so the siblings show the would-be
|
||||
/// order) and by the drag's own centre arithmetic, so the two can never disagree about what the
|
||||
/// proposal means.
|
||||
static func reordered<T>(_ items: [T], from: Int, to: Int) -> [T] {
|
||||
guard items.indices.contains(from) else { return items }
|
||||
var result = items
|
||||
let item = result.remove(at: from)
|
||||
result.insert(item, at: min(max(0, to), result.count))
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import CoreGraphics
|
||||
import Observation
|
||||
|
||||
/// Window-local state for an in-flight lane reorder — the drag surface being the whole title bar
|
||||
/// (03-board-ui.md § Lane, "no separate grip"). At most one runs per board window; `BoardView` owns
|
||||
/// it as `@State` and hands it to the lanes.
|
||||
///
|
||||
/// `LaneResizeSession`'s sibling, and deliberately much smaller. It holds only what the *pointer*
|
||||
/// contributes — which lane, how far it has travelled, and the centre it started from — because
|
||||
/// everything else the proposal needs is read fresh from the snapshot at render time
|
||||
/// (`LaneReorderMath.proposedIndex`). That split is 04-interactions.md ▸ Drag and drop's
|
||||
/// re-grounding rule made structural: "the frozen-at-drag-start inputs are the *dragged items'*
|
||||
/// sizes and the physical pointer only … the analytic resting zones recompute against each new
|
||||
/// snapshot", so a foreign lane add mid-drag cannot leave this session holding a stale board.
|
||||
///
|
||||
/// ### The click-versus-drag split
|
||||
///
|
||||
/// A plain click on the title bar selects the lane; only movement past `threshold` begins a
|
||||
/// reorder (04 ▸ Selection: "the drag surface engages only on movement — the click-vs-drag split
|
||||
/// cards already have"). One gesture recognises both, so a hesitant click can never start a drag
|
||||
/// and a drag can never also select.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class LaneReorderSession {
|
||||
|
||||
/// The lane being dragged; `nil` when idle. Observed — flipping it drives the travelling lane's
|
||||
/// z-order and offset, and the siblings' reflow into the proposed order.
|
||||
private(set) var laneID: ItemID?
|
||||
|
||||
/// How far the pointer has travelled horizontally since the drag began. The *only* live input:
|
||||
/// vertical movement is ignored outright, since lanes reorder along one axis.
|
||||
private(set) var translation: CGFloat = 0
|
||||
|
||||
/// The dragged lane's resting centre at drag start, in strip coordinates — the origin
|
||||
/// `translation` is measured from, frozen exactly as 03-board-ui.md § Motion requires.
|
||||
@ObservationIgnored private(set) var startCentre: CGFloat = 0
|
||||
|
||||
/// How far the pointer must move before a click becomes a drag. Small enough that a deliberate
|
||||
/// drag feels immediate, large enough that the tremor in a click never reorders the board.
|
||||
static let threshold: CGFloat = 4
|
||||
|
||||
var isActive: Bool { laneID != nil }
|
||||
|
||||
func isDragging(_ id: ItemID) -> Bool { laneID == id }
|
||||
|
||||
/// The dragged lane's centre under the cursor: the frozen start plus the physical translation,
|
||||
/// and nothing measured.
|
||||
var centre: CGFloat { startCentre + translation }
|
||||
|
||||
/// Begins a reorder of `laneID`, freezing the centre its travel is measured from.
|
||||
func begin(laneID: ItemID, startCentre: CGFloat) {
|
||||
self.laneID = laneID
|
||||
self.startCentre = startCentre
|
||||
self.translation = 0
|
||||
}
|
||||
|
||||
func update(translation: CGFloat) {
|
||||
guard isActive else { return }
|
||||
self.translation = translation
|
||||
}
|
||||
|
||||
/// Ends the drag, handing the caller nothing: the *commit* needs the current snapshot's lane
|
||||
/// order, which `BoardView` has and this session deliberately does not. Idempotent, because a
|
||||
/// gesture can end after the lane it was carrying has already vanished.
|
||||
func end() {
|
||||
laneID = nil
|
||||
translation = 0
|
||||
}
|
||||
}
|
||||
+332
-65
@@ -1,24 +1,6 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - The strip's half of the header drag
|
||||
|
||||
/// What the strip lends a lane so its **whole title bar** can be the drag surface (03-board-ui.md §
|
||||
/// Lane, "no separate grip").
|
||||
///
|
||||
/// The gesture lives on the header — that is where the design puts it — but the two things it needs
|
||||
/// are the strip's: where this lane currently rests (the origin the translation is measured from)
|
||||
/// and what a release means (a proposal computed against the live lane order, then a write). Both
|
||||
/// arrive as closures rather than as values because both must be read at *gesture* time, not at
|
||||
/// body-evaluation time.
|
||||
@MainActor
|
||||
struct LaneHeaderDrag {
|
||||
/// This lane's resting centre in strip coordinates, read the instant the drag begins and frozen
|
||||
/// for its duration (`LaneReorderSession.startCentre`).
|
||||
let startCentre: () -> CGFloat
|
||||
/// Commit the reorder at whatever the current proposal is, and end the session.
|
||||
let commit: () -> Void
|
||||
}
|
||||
|
||||
// MARK: - LaneView
|
||||
|
||||
/// One lane: a title bar and a vertically scrolling masonry of cards (03-board-ui.md § Lane).
|
||||
@@ -29,9 +11,11 @@ struct LaneHeaderDrag {
|
||||
/// (`ItemSymbol`) — then the title or its quiet "Untitled" placeholder, a quiet secondary
|
||||
/// card-count badge, and a trailing quiet new-card button. **The whole bar is the drag surface**:
|
||||
/// a click selects the lane — toggling off on a repeat, exactly as empty space does
|
||||
/// (04-interactions.md § Selection, settled) — and movement past a small threshold begins a reorder
|
||||
/// (`LaneReorderSession`). The one thing carved out of the drag region is the button, which sits in
|
||||
/// an overlay outside the gesture so a click on it can never be read as the beginning of a drag.
|
||||
/// (04-interactions.md § Selection, settled) — and movement begins a **system drag session**
|
||||
/// carrying the lane (`DragSession`, DRAG-REORDER.md). The click-versus-drag split is the system's
|
||||
/// own now: `.onTapGesture` and `.onDrag` coexist, so a hesitant click can never start a drag and a
|
||||
/// drag can never also select. The one thing carved out of the drag region is the new-card button,
|
||||
/// which sits in an overlay outside it.
|
||||
///
|
||||
/// ### The lane's one context menu
|
||||
///
|
||||
@@ -60,10 +44,14 @@ struct LaneView: View {
|
||||
/// override it (see `BoardView.laneSlot`).
|
||||
let columns: Int
|
||||
|
||||
/// The strip's reorder session, so the header knows whether *it* is the lane in flight.
|
||||
let reorder: LaneReorderSession
|
||||
/// This lane's resting slot width — the replica's width, so the image under the cursor is the
|
||||
/// lane at its real on-screen size (03-board-ui.md § Motion: "a faithful, full-size replica").
|
||||
let slotWidth: CGFloat
|
||||
|
||||
let headerDrag: LaneHeaderDrag
|
||||
/// The board window's drop machinery: the app-wide session, the geometry registry this lane
|
||||
/// registers its card grid into, and the shared retarget every hover and every autoscroll step
|
||||
/// goes through (`BoardDropContext`).
|
||||
let drops: BoardDropContext
|
||||
|
||||
/// The strip's rubber band: the lane's empty space is one of its three surfaces, and every card
|
||||
/// face registers its frame into the same registry (`MarqueeControl`).
|
||||
@@ -88,6 +76,14 @@ struct LaneView: View {
|
||||
/// C7 · full-column top edge (03-board-ui.md § Styling ▸ Capabilities).
|
||||
private let bandHeight: CGFloat = 5
|
||||
|
||||
/// The lane's drawn height, for the replica. Measured rather than derived, because a lane is as
|
||||
/// tall as the strip gives it.
|
||||
@State private var measuredHeight: CGFloat = 0
|
||||
|
||||
/// This lane's edge-autoscroll driver — one per lane, ticking only while a card session is in
|
||||
/// flight (`DragAutoScroller`, DRAG-REORDER.md § Edge autoscroll).
|
||||
@State private var autoScroller = DragAutoScroller()
|
||||
|
||||
var body: some View {
|
||||
// `spacing: 0` and the padding moved inside: the accent band is **full-width** along the
|
||||
// lane's top edge, so it must sit outside the content inset rather than in it.
|
||||
@@ -101,6 +97,14 @@ struct LaneView: View {
|
||||
}
|
||||
.background(selectionBackground)
|
||||
.overlay(selectionStroke)
|
||||
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { measuredHeight = $0 }
|
||||
// **This lane's drop target**, on the whole body. It accepts *both* board types and routes
|
||||
// internally — card sessions against this lane's masonry zones, lane sessions forwarded to
|
||||
// the strip's logic — because single-target dispatch has no fall-through (DRAG-REORDER.md).
|
||||
//
|
||||
// m5-finder-drops: external Finder file sessions join this same target and this same
|
||||
// routing; the type list grows by `.fileURL` and the delegate by one branch.
|
||||
.onDrop(of: boardDragTypes, delegate: LaneDropDelegate(context: drops, laneID: lane.id))
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
@@ -110,7 +114,20 @@ struct LaneView: View {
|
||||
// The bar is the drag surface, so it must be hit-testable across its whole width —
|
||||
// including the empty stretch between the badge and the button.
|
||||
.contentShape(Rectangle())
|
||||
.gesture(headerGesture)
|
||||
// **The header toggles like empty space** (04-interactions.md § Selection, settled): "a
|
||||
// click on the already-selected lane's header unselects, one lane-click behavior
|
||||
// everywhere, so a full lane keeps a pointer path out of selection". Hence the same
|
||||
// `togglesOnRepeat` the empty space passes — the two surfaces differ only in where they
|
||||
// are. `.onTapGesture` beside `.onDrag` is the click-versus-drag split: the system holds
|
||||
// the drag off until the pointer actually moves, so a click is never a drag.
|
||||
.onTapGesture {
|
||||
store.click(
|
||||
SelectionTarget(id: lane.id, kind: .lane, side: .live),
|
||||
modifier: .current,
|
||||
togglesOnRepeat: true
|
||||
)
|
||||
}
|
||||
.onDrag(startLaneDrag, preview: { dragReplica })
|
||||
.overlay(alignment: .trailing) { newCardButton }
|
||||
.contextMenu { laneMenu }
|
||||
// The lane's half of the Style… popover. Anchored on the header because that is the
|
||||
@@ -272,40 +289,106 @@ struct LaneView: View {
|
||||
.disabled(store.isReadOnly || store.isEditingInline)
|
||||
}
|
||||
|
||||
/// One gesture recognising both halves of 04-interactions.md ▸ Selection's click-vs-drag split:
|
||||
/// "a plain click on the title bar selects the lane; the drag surface engages only on movement".
|
||||
// MARK: - The lane drag
|
||||
|
||||
/// Begins the lane's system drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
||||
///
|
||||
/// `minimumDistance: 0` so the release is seen even when nothing moved — that release *is* the
|
||||
/// click. `.global` coordinates because the strip's own space shifts as siblings reflow under
|
||||
/// the proposal, and a translation measured against a moving frame is not a pointer delta.
|
||||
private var headerGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 0, coordinateSpace: .global)
|
||||
.onChanged { value in
|
||||
// Selection stays live under the lock; reordering does not (02 § The lock's scope).
|
||||
// The focused-editor rule holds a drag off too: a reorder is a board command.
|
||||
guard !store.isReadOnly, !store.isEditingInline else { return }
|
||||
if !reorder.isDragging(lane.id) {
|
||||
guard abs(value.translation.width) > LaneReorderSession.threshold else { return }
|
||||
reorder.begin(laneID: lane.id, startCentre: headerDrag.startCentre())
|
||||
}
|
||||
reorder.update(translation: value.translation.width)
|
||||
/// **Dragging any member of a multi-selection drags the whole selection**, in board order —
|
||||
/// which is the lane level's flatten order. A lane outside the selection drags alone, standard
|
||||
/// macOS targeting.
|
||||
///
|
||||
/// Refused under the read-only lock and while an inline editor is focused, like every other
|
||||
/// mutating gesture (02-architecture.md § The lock's scope; 04 ▸ Grammar's focused-editor rule).
|
||||
/// A refusal is an item provider carrying nothing: no session begins, every drop target declines,
|
||||
/// and the image snaps back.
|
||||
private func startLaneDrag() -> NSItemProvider {
|
||||
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
||||
let selection = store.selection
|
||||
let ids: Set<ItemID> = selection.liveness == .live
|
||||
&& selection.ids.contains(lane.id)
|
||||
&& selection.ids.count > 1
|
||||
? selection.ids
|
||||
: [lane.id]
|
||||
|
||||
let members = store.snapshot.lanes.filter { !$0.isDeleted && ids.contains($0.id) }
|
||||
guard !members.isEmpty else { return NSItemProvider() }
|
||||
|
||||
let root = store.rootURL
|
||||
let payload = DragPayload(
|
||||
boardRoot: root,
|
||||
kind: .lanes,
|
||||
side: .live,
|
||||
items: members.map {
|
||||
DragPayload.Item(
|
||||
id: $0.id.rawValue,
|
||||
folder: root.appendingPathComponent($0.id.rawValue, isDirectory: true).path,
|
||||
title: $0.title.value
|
||||
)
|
||||
}
|
||||
.onEnded { _ in
|
||||
if reorder.isDragging(lane.id) {
|
||||
headerDrag.commit()
|
||||
} else {
|
||||
// **The header toggles like empty space** (04-interactions.md § Selection,
|
||||
// settled): "a click on the already-selected lane's header unselects, one
|
||||
// lane-click behavior everywhere, so a full lane keeps a pointer path out of
|
||||
// selection". Hence the same `togglesOnRepeat` the empty space passes — the two
|
||||
// surfaces differ only in where they are.
|
||||
store.click(
|
||||
SelectionTarget(id: lane.id, kind: .lane, side: .live),
|
||||
modifier: .current,
|
||||
togglesOnRepeat: true
|
||||
)
|
||||
)
|
||||
drops.session.beginLanes(
|
||||
members.map(\.id),
|
||||
folders: payload.folders,
|
||||
// The dragged items' own sizes, frozen at drag start — the one thing that is
|
||||
// (03-board-ui.md § Motion).
|
||||
units: members.map { LaneLayoutMath.displayUnits(of: $0) },
|
||||
source: store
|
||||
)
|
||||
return payload.itemProvider()
|
||||
}
|
||||
|
||||
/// The image travelling under the cursor: **a faithful, full-size replica of the whole lane**,
|
||||
/// not the strip of title bar that was grabbed (03-board-ui.md § Motion), fanned with ghosts and
|
||||
/// a count badge for a multi-drag.
|
||||
///
|
||||
/// A static rendition rather than a live `LaneView`: a drag image is a snapshot, so it carries no
|
||||
/// scrolling, no gestures and no geometry observers, and the card list is capped because anything
|
||||
/// past the lane's height is clipped anyway.
|
||||
private var dragReplica: some View {
|
||||
let count = max(1, draggedLaneCount)
|
||||
return ZStack {
|
||||
if count > 2 { replicaFace.offset(x: 12, y: 12).opacity(0.45) }
|
||||
if count > 1 { replicaFace.offset(x: 6, y: 6).opacity(0.7) }
|
||||
replicaFace
|
||||
}
|
||||
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
|
||||
.padding(12)
|
||||
}
|
||||
|
||||
private var draggedLaneCount: Int {
|
||||
let selection = store.selection
|
||||
guard selection.liveness == .live, selection.ids.contains(lane.id) else { return 1 }
|
||||
return selection.ids.count
|
||||
}
|
||||
|
||||
private var replicaFace: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
accentBand
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
headerContent
|
||||
VStack(alignment: .leading, spacing: cardSpacing) {
|
||||
ForEach(renderedCards.prefix(12)) { card in
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||||
.foregroundStyle(.secondary)
|
||||
.imageScale(.medium)
|
||||
Text(card.title.value ?? "Untitled")
|
||||
.font(.body)
|
||||
.lineLimit(2)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(.background.secondary))
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
.padding(6)
|
||||
}
|
||||
.frame(width: max(slotWidth, 80), height: max(measuredHeight, 120), alignment: .topLeading)
|
||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background))
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
@@ -343,10 +426,16 @@ struct LaneView: View {
|
||||
store: store,
|
||||
card: card,
|
||||
registry: marquee.registry,
|
||||
drops: drops,
|
||||
openCard: openCard
|
||||
)
|
||||
case .placeholder:
|
||||
NewCardStubView(store: store, openCard: openCard)
|
||||
case let .shadow(_, height):
|
||||
// One of the drag's N contiguous shadows, at the dragged card's frozen
|
||||
// height — the run's real footprint, so the drop lands exactly here.
|
||||
DragShadow(cornerRadius: 8)
|
||||
.frame(height: height)
|
||||
}
|
||||
}
|
||||
// "Appear/disappear is scale + fade (cards scale from ~0.8 …)"
|
||||
@@ -363,6 +452,30 @@ struct LaneView: View {
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
// The drag's reflow-to-make-room inside the lane, keyed on **this lane's drop proposal**
|
||||
// and nothing broader (03-board-ui.md § Motion). `MasonryLayout` is a `Layout` over one
|
||||
// `ForEach` precisely so the round-robin reshuffle animates as positional slides rather
|
||||
// than as remove/insert blinks (DRAG-REORDER.md § The card masonry).
|
||||
.animation(Motion.dragReflow(reduced: reduceMotion), value: cardProposal)
|
||||
// Where this lane's card grid is drawn, in the window's global space — the analytic
|
||||
// resting grid the drop model replays `MasonryPlacement` over. Registered rather than
|
||||
// re-derived, so the zones and the drawn grid cannot disagree.
|
||||
.onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { frame in
|
||||
drops.registry.update(
|
||||
LaneDropRegistry.Grid(frame: frame, columns: max(1, columns), spacing: cardSpacing),
|
||||
for: lane.id
|
||||
)
|
||||
}
|
||||
.onDisappear { drops.registry.removeGrid(lane.id) }
|
||||
// The edge-autoscroll anchor, **inside** the scroll view's content so
|
||||
// `enclosingScrollView` resolves (`DragAutoScrollAnchor`). Every scroll step re-resolves
|
||||
// the proposal through the same shared retarget the drop delegate uses, because the
|
||||
// cursor is stationary while the content moves under it.
|
||||
.background {
|
||||
DragAutoScrollAnchor(scroller: autoScroller) {
|
||||
drops.retargetCards(inLane: lane.id)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
// Order matters: the two-tap recogniser must be attached first so a double click is not
|
||||
// consumed as two singles.
|
||||
@@ -387,22 +500,51 @@ struct LaneView: View {
|
||||
// space alike" (03-board-ui.md § Lane, settled).
|
||||
.contextMenu { laneMenu }
|
||||
}
|
||||
// The autoscroll driver, **structurally terminated**: a `.task(id:)` keyed on whether a card
|
||||
// session is in flight at all, so it is cancelled the moment the session ends — and
|
||||
// `DragSession`'s watchdog guarantees that flag clears however the drag finished
|
||||
// (DRAG-REORDER.md § Edge autoscroll). Within a session, a pointer outside this lane's
|
||||
// engagement rect simply scrolls nothing.
|
||||
.task(id: drops.session.isDraggingCards) {
|
||||
guard drops.session.isDraggingCards else { return }
|
||||
await autoScroller.run()
|
||||
}
|
||||
}
|
||||
|
||||
/// What the masonry lays out: the rendered cards, plus the new-card placeholder when this lane
|
||||
/// is the one being created into.
|
||||
/// Where a card drag would land **in this lane**, or `nil` when the proposal is elsewhere — the
|
||||
/// shadow run's position, and the reflow's narrow animation key.
|
||||
private var cardProposal: Int? {
|
||||
drops.session.laneProposal(onBoardRooted: store.rootURL, laneID: lane.id)
|
||||
}
|
||||
|
||||
/// What the masonry lays out: the rendered cards, the drag's N contiguous shadows at the
|
||||
/// proposal, and the new-card placeholder when this lane is the one being created into.
|
||||
///
|
||||
/// The overlay is inserted **at the position the card will actually take** — after its anchor
|
||||
/// The placeholder is inserted **at the position the card will actually take** — after its anchor
|
||||
/// for ⌘N's "immediately after it", at the bottom otherwise — by asking the very function the
|
||||
/// commit uses to compute the rank (`BoardStore.insertionIndex`). One answer, so the pseudo-card
|
||||
/// cannot appear anywhere but where the real card lands.
|
||||
/// cannot appear anywhere but where the real card lands. Both insertions are computed against
|
||||
/// `renderedCards`, and the placeholder's is shifted past a shadow run that opened in front of
|
||||
/// it, so neither displaces the other.
|
||||
private var slots: [LaneSlot] {
|
||||
var result = renderedCards.map(LaneSlot.card)
|
||||
guard let placeholder = store.transient.newCardPlaceholder, placeholder.laneID == lane.id else {
|
||||
return result
|
||||
|
||||
let shadowPosition = cardProposal.map { min(max(0, $0), result.count) }
|
||||
var placeholderPosition: Int?
|
||||
if let placeholder = store.transient.newCardPlaceholder, placeholder.laneID == lane.id {
|
||||
placeholderPosition = BoardStore.insertionIndex(after: placeholder.anchorCardID, among: renderedCards)
|
||||
?? result.count
|
||||
}
|
||||
|
||||
let heights = drops.session.cardHeights
|
||||
if let shadowPosition {
|
||||
let shadows = heights.enumerated().map { LaneSlot.shadow(index: $0.offset, height: $0.element) }
|
||||
result.insert(contentsOf: shadows, at: shadowPosition)
|
||||
}
|
||||
if var position = placeholderPosition {
|
||||
if let shadowPosition, position >= shadowPosition { position += heights.count }
|
||||
result.insert(.placeholder, at: min(position, result.count))
|
||||
}
|
||||
let position = BoardStore.insertionIndex(after: placeholder.anchorCardID, among: renderedCards)
|
||||
result.insert(.placeholder, at: position ?? result.count)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -411,10 +553,17 @@ struct LaneView: View {
|
||||
/// liveness). The lane half of that rule is `BoardView`'s, which never builds a `LaneView` for a
|
||||
/// tombstoned lane at all.
|
||||
///
|
||||
/// **A dragged card renders nowhere either, for as long as the session lasts.** It is lifted out
|
||||
/// of the resting layout at pickup and stays out until release *whatever the effective operation
|
||||
/// is* — a ⌥-copy's originals really do stay, but ⌥ can be pressed and released mid-drag, and a
|
||||
/// layout that re-admitted them on every flip would flap the board under the cursor
|
||||
/// (DRAG-REORDER.md § Resting-layout zones). The copy's originals reappear when the write lands.
|
||||
///
|
||||
/// This is also the collection m5's search filter narrows, which is what keeps the count badge
|
||||
/// honest for free — see `countBadge`.
|
||||
private var renderedCards: [Card] {
|
||||
lane.cards.filter { !$0.isDeleted }
|
||||
let hidden = drops.session.hiddenMembers(onBoardRooted: store.rootURL)
|
||||
return lane.cards.filter { !$0.isDeleted && !hidden.contains($0.id) }
|
||||
}
|
||||
|
||||
// MARK: - Selection
|
||||
@@ -464,6 +613,8 @@ struct LaneView: View {
|
||||
private enum LaneSlot: Identifiable {
|
||||
case card(Card)
|
||||
case placeholder
|
||||
/// One of a drag's N contiguous shadows, at the dragged card's frozen height.
|
||||
case shadow(index: Int, height: CGFloat)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
@@ -471,6 +622,9 @@ private enum LaneSlot: Identifiable {
|
||||
// Constant, because there is only ever one placeholder in one lane at a time and it must
|
||||
// keep its identity — and therefore its keyboard focus — while the user types.
|
||||
case .placeholder: "placeholder"
|
||||
// Constant per position in the run, so the shadows animate as slides when the proposal moves
|
||||
// rather than blinking out and back in.
|
||||
case let .shadow(index, _): "shadow:\(index)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,6 +672,10 @@ private struct CardFaceView: View {
|
||||
/// here and takes it out again when it leaves — see `View.marqueeTarget`.
|
||||
let registry: MarqueeTargetRegistry
|
||||
|
||||
/// The board window's drop machinery: this face registers its measured height into the geometry
|
||||
/// registry (the resting grid's input) and starts the card drag session from `.onDrag`.
|
||||
let drops: BoardDropContext
|
||||
|
||||
let openCard: (ItemID) -> Void
|
||||
|
||||
/// The app-wide quick-style recents — see `LaneView`'s own note.
|
||||
@@ -572,6 +730,17 @@ private struct CardFaceView: View {
|
||||
guard ClickModifier.current == .plain else { return }
|
||||
openCard(card.id)
|
||||
})
|
||||
// **The whole face is the drag surface** (04-interactions.md ▸ Drag and drop). `.onDrag`
|
||||
// beside the tap recognisers above is the click-versus-drag split, the system's own: it holds
|
||||
// the session off until the pointer really moves, so selecting and opening stay instant.
|
||||
.onDrag(startCardDrag, preview: { dragReplica })
|
||||
// The card's height, for the drop model's analytic resting grid. A height is content-driven
|
||||
// and does not animate under the reflow — only positions do, and those are never measured
|
||||
// (`LaneDropRegistry`).
|
||||
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in
|
||||
drops.registry.update(height: height, for: card.id)
|
||||
}
|
||||
.onDisappear { drops.registry.removeHeight(card.id) }
|
||||
.marqueeTarget(card.id, kind: .card, side: .live, in: registry)
|
||||
.contextMenu { cardMenu }
|
||||
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
|
||||
@@ -579,6 +748,104 @@ private struct CardFaceView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The card drag
|
||||
|
||||
/// Begins the card's system drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
||||
///
|
||||
/// **Dragging any member of a multi-selection drags the whole selection**, in **flatten order** —
|
||||
/// "lane `order` first, then card `order`", `SelectionGrammar.liveCards`' single definition of
|
||||
/// it, which is also the order the drop inserts in. A card outside the selection drags alone.
|
||||
///
|
||||
/// Refused under the read-only lock and while an inline editor is focused, like every other
|
||||
/// mutating gesture; a refusal is an item provider carrying nothing, so no session begins.
|
||||
private func startCardDrag() -> NSItemProvider {
|
||||
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
||||
let snapshot = store.snapshot
|
||||
let selection = store.selection
|
||||
let ids: Set<ItemID> = selection.liveness == .live
|
||||
&& selection.ids.contains(card.id)
|
||||
&& selection.ids.count > 1
|
||||
? selection.ids
|
||||
: [card.id]
|
||||
|
||||
// Flatten order, and the lane each member currently lives in — the folder path's middle
|
||||
// component.
|
||||
var lanesByCard: [ItemID: ItemID] = [:]
|
||||
var titles: [ItemID: String?] = [:]
|
||||
for lane in snapshot.lanes where !lane.isDeleted {
|
||||
for member in lane.cards where !member.isDeleted && ids.contains(member.id) {
|
||||
lanesByCard[member.id] = lane.id
|
||||
titles[member.id] = member.title.value
|
||||
}
|
||||
}
|
||||
let ordered = SelectionGrammar.liveCards(in: snapshot).filter { ids.contains($0) }
|
||||
guard !ordered.isEmpty else { return NSItemProvider() }
|
||||
|
||||
let root = store.rootURL
|
||||
let payload = DragPayload(
|
||||
boardRoot: root,
|
||||
kind: .cards,
|
||||
side: .live,
|
||||
items: ordered.compactMap { id in
|
||||
guard let laneID = lanesByCard[id] else { return nil }
|
||||
return DragPayload.Item(
|
||||
id: id.rawValue,
|
||||
folder: root
|
||||
.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(id.rawValue, isDirectory: true)
|
||||
.path,
|
||||
title: titles[id] ?? nil
|
||||
)
|
||||
}
|
||||
)
|
||||
drops.session.beginCards(
|
||||
ordered,
|
||||
folders: payload.folders,
|
||||
// The dragged items' sizes, frozen at drag start — the pickup transition scales the
|
||||
// replica, and its lingering "last measured frame" would mis-size the shadow and the
|
||||
// span-cap (03-board-ui.md § Motion).
|
||||
heights: ordered.map { drops.registry.heights[$0] ?? LaneDropRegistry.nominalCardHeight },
|
||||
side: .live,
|
||||
source: store
|
||||
)
|
||||
return payload.itemProvider()
|
||||
}
|
||||
|
||||
/// The image travelling under the cursor: this card's face at its real size, fanned with ghosts
|
||||
/// and a count badge when the whole multi-selection rides along (03-board-ui.md § Motion).
|
||||
private var dragReplica: some View {
|
||||
let count = store.selection.liveness == .live && store.selection.ids.contains(card.id)
|
||||
? max(1, store.selection.ids.count)
|
||||
: 1
|
||||
return ZStack {
|
||||
if count > 2 { replicaFace.offset(x: 10, y: 10).opacity(0.45) }
|
||||
if count > 1 { replicaFace.offset(x: 5, y: 5).opacity(0.7) }
|
||||
replicaFace
|
||||
}
|
||||
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
|
||||
.padding(12)
|
||||
}
|
||||
|
||||
/// A static rendition of the face — a drag image is a snapshot, so it carries no gestures, no
|
||||
/// editor and no geometry observers.
|
||||
private var replicaFace: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||||
.foregroundStyle(iconTint)
|
||||
.imageScale(.medium)
|
||||
Text(card.title.value ?? "Untitled")
|
||||
.font(.body)
|
||||
.lineLimit(4)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
attachmentsIndicator
|
||||
}
|
||||
.padding(10)
|
||||
.padding(.leading, stripeWidth)
|
||||
.frame(width: 220, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
|
||||
.overlay(alignment: .leading) { accentStripe }
|
||||
}
|
||||
|
||||
// MARK: - Context menu
|
||||
|
||||
/// Open, Rename, Style…, the quick-style recents row, Delete (11-command-nexus.md ▸ Context
|
||||
|
||||
@@ -3,7 +3,7 @@ import Observation
|
||||
|
||||
// MARK: - MarqueeSession
|
||||
|
||||
/// Window-local state for an in-flight rubber band — `LaneReorderSession`'s sibling, and as small
|
||||
/// Window-local state for an in-flight rubber band — `LaneResizeSession`'s sibling, and as small
|
||||
/// for its reason: it holds only what the *pointer* contributes, because everything the selection
|
||||
/// needs beyond that is read fresh at gesture time (`MarqueeTargetRegistry`, `MarqueeMath`).
|
||||
///
|
||||
|
||||
@@ -29,7 +29,7 @@ extension ClickModifier {
|
||||
/// What a board window lends its empty surfaces so each can be a rubber band: the one session, the
|
||||
/// one target registry, and the store the band selects into.
|
||||
///
|
||||
/// `LaneHeaderDrag`'s sibling in role — the strip owning state that a leaf gesture needs — but a
|
||||
/// `BoardDropContext`'s sibling in role — the strip owning state that a leaf gesture needs — but a
|
||||
/// value rather than a pair of closures, because all three surfaces (lane empty space, the board
|
||||
/// backdrop, the trash column) want the *same* gesture rather than three variations threaded with
|
||||
/// different geometry. Only the side differs, and that is the parameter.
|
||||
|
||||
+107
-116
@@ -2,70 +2,6 @@ import AppKit
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - The strip's half of a trash row's drag
|
||||
|
||||
/// What the strip lends the trash so a row can be dragged back onto the board (03-board-ui.md §
|
||||
/// Trash ▸ Drag-to-restore).
|
||||
///
|
||||
/// `LaneHeaderDrag`'s sibling, and for its reason: the gesture lives on the row, but the one thing it
|
||||
/// needs — *which lane is under the cursor* — is the strip's geometry, and it must be read at gesture
|
||||
/// time rather than at body-evaluation time.
|
||||
@MainActor
|
||||
struct TrashRowDrag {
|
||||
/// The live lane under an x in strip coordinates, or `nil` when the point is not over one — a
|
||||
/// gap, the outer margin, or the trash itself (`LaneLayoutMath.laneIndex`).
|
||||
let laneUnder: (CGFloat) -> ItemID?
|
||||
}
|
||||
|
||||
// MARK: - TrashDragSession
|
||||
|
||||
/// Window-local state for an in-flight drag out of the trash — `LaneReorderSession`'s sibling, and
|
||||
/// deliberately as small.
|
||||
///
|
||||
/// It holds only what the pointer contributes: which card, and which lane is currently under it.
|
||||
/// Everything else — the strip's geometry, the lanes themselves — is read fresh at render time, so a
|
||||
/// foreign reload mid-drag cannot leave this holding a stale board (04-interactions.md ▸ Drag and
|
||||
/// drop's re-grounding rule).
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TrashDragSession {
|
||||
|
||||
/// The card being dragged out of the trash; `nil` when idle.
|
||||
private(set) var cardID: ItemID?
|
||||
|
||||
/// The live lane the pointer is over, or `nil` when it is over nothing droppable. Observed: the
|
||||
/// lanes read it to draw the drop highlight, which is this milestone's whole visual feedback.
|
||||
private(set) var targetLaneID: ItemID?
|
||||
|
||||
/// How far the pointer must travel before a click on a row becomes a drag — the same threshold
|
||||
/// the lane header uses, so the two gestures feel alike.
|
||||
static let threshold: CGFloat = 4
|
||||
|
||||
var isActive: Bool { cardID != nil }
|
||||
|
||||
func isDragging(_ id: ItemID) -> Bool { cardID == id }
|
||||
|
||||
func isTarget(_ id: ItemID) -> Bool { targetLaneID == id }
|
||||
|
||||
func begin(cardID: ItemID) {
|
||||
self.cardID = cardID
|
||||
targetLaneID = nil
|
||||
}
|
||||
|
||||
func update(targetLaneID: ItemID?) {
|
||||
guard isActive else { return }
|
||||
self.targetLaneID = targetLaneID
|
||||
}
|
||||
|
||||
/// Ends the drag, handing the caller nothing — the *commit* needs the current snapshot, which
|
||||
/// the view has and this session deliberately does not. Idempotent, because a gesture can end
|
||||
/// after the card it was carrying has already vanished.
|
||||
func end() {
|
||||
cardID = nil
|
||||
targetLaneID = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TrashLaneView
|
||||
|
||||
/// The trash quasi-lane: the trailing, visually distinct column tombstoned items live in
|
||||
@@ -81,8 +17,11 @@ final class TrashDragSession {
|
||||
/// the edge drag never reaches it (`LaneLayoutMath.totalUnits(of:trashUnits:)` supplies the unit,
|
||||
/// so there is no `Lane` value for any of those to act on);
|
||||
/// - it is **not draggable and not reorderable** — the header carries no gesture, and it is absent
|
||||
/// from the reorder proposal's `unitCounts` by construction, since `BoardView` builds that from
|
||||
/// the snapshot's live lanes;
|
||||
/// from the drop proposal's slot list by construction, since `BoardView` builds that from the
|
||||
/// snapshot's live lanes;
|
||||
/// - it is **never a drop target** — "no move or paste ever targets the trash" (04-interactions.md ▸
|
||||
/// The trash), so nothing here declares an `onDrop` at all and a session over the column falls
|
||||
/// through to the strip's own target, where a cursor over no lane simply holds the proposal;
|
||||
/// - it has **no new-card button**: nothing is created in the trash.
|
||||
///
|
||||
/// ### No editing in the trash
|
||||
@@ -110,12 +49,9 @@ struct TrashLaneView: View {
|
||||
/// does.
|
||||
let confirmations: TrashConfirmations
|
||||
|
||||
/// The strip's drop resolution — see `TrashRowDrag`.
|
||||
let drag: TrashRowDrag
|
||||
|
||||
/// The window's one drag-out session, owned by `BoardView` for the same lifetime the reorder
|
||||
/// session has.
|
||||
let dragSession: TrashDragSession
|
||||
/// The board window's drop machinery — a row's drag is an ordinary card session on the
|
||||
/// **trashed** side (`DragSession`, 04-interactions.md ▸ The trash).
|
||||
let drops: BoardDropContext
|
||||
|
||||
/// The strip's rubber band. The column's empty space is its third surface, on the **trashed**
|
||||
/// side — "a rubber-band stays on the side of the boundary it started on" (04-interactions.md ▸
|
||||
@@ -234,8 +170,7 @@ struct TrashLaneView: View {
|
||||
store: store,
|
||||
entry: entry,
|
||||
confirmations: confirmations,
|
||||
drag: drag,
|
||||
dragSession: dragSession,
|
||||
drops: drops,
|
||||
registry: marquee.registry
|
||||
)
|
||||
// A row is a tombstoned item, so it arrives and leaves in the card's dialect —
|
||||
@@ -295,8 +230,7 @@ private struct TrashEntryRow: View {
|
||||
let store: BoardStore
|
||||
let entry: TrashEntry
|
||||
let confirmations: TrashConfirmations
|
||||
let drag: TrashRowDrag
|
||||
let dragSession: TrashDragSession
|
||||
let drops: BoardDropContext
|
||||
|
||||
/// Where the rubber band looks up what it is sweeping — the card face's rule, on the trashed
|
||||
/// side (`View.marqueeTarget`).
|
||||
@@ -304,7 +238,38 @@ private struct TrashEntryRow: View {
|
||||
|
||||
private let cornerRadius: CGFloat = 6
|
||||
|
||||
/// **Lane entries are not draggable** (03-board-ui.md § Trash: "a lane entry is not draggable —
|
||||
/// its entry is a compact row, not the lane; its move-out is Put Back"), so the drag half is
|
||||
/// simply *absent* for them rather than refused — no session, no image, no snap-back. A click
|
||||
/// still selects either way.
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
if entry.isLaneEntry {
|
||||
plate
|
||||
} else {
|
||||
plate.onDrag(startRowDrag, preview: { dragReplica })
|
||||
}
|
||||
}
|
||||
|
||||
private var plate: some View {
|
||||
rowFace
|
||||
// The row being dragged out dims in place — the source stays visible in the trash,
|
||||
// because a restore is not a removal until the write lands.
|
||||
.opacity(drops.session.isDragging(entry.id) ? 0.45 : 1)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { select() }
|
||||
.marqueeTarget(entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed, in: registry)
|
||||
.contextMenu { menu }
|
||||
}
|
||||
|
||||
/// The plate's *appearance*, with none of its behaviour — no gesture, no context menu, and
|
||||
/// crucially no marquee registration.
|
||||
///
|
||||
/// The split exists for the drag replica, which renders this and nothing else: a drag image is a
|
||||
/// snapshot, and one built out of the live plate would re-register this row's frame from inside
|
||||
/// the preview's own geometry and then *deregister* it when the image went away, quietly
|
||||
/// stealing the row from the rubber band and the arrow keys.
|
||||
private var rowFace: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Image(systemName: ItemSymbol.name(entry.icon, fallback: symbolFallback))
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -331,12 +296,6 @@ private struct TrashEntryRow: View {
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5)
|
||||
)
|
||||
// The row being dragged out dims further, so the gesture reads even without a replica.
|
||||
.opacity(dragSession.isDragging(entry.id) ? 0.45 : 1)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(rowGesture)
|
||||
.marqueeTarget(entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed, in: registry)
|
||||
.contextMenu { menu }
|
||||
}
|
||||
|
||||
private var symbolFallback: String {
|
||||
@@ -367,46 +326,78 @@ private struct TrashEntryRow: View {
|
||||
|
||||
// MARK: - Drag out
|
||||
|
||||
/// One gesture recognising the same click-versus-drag split the lane header uses: a plain click
|
||||
/// selects, and only movement past the threshold begins a drag out of the trash.
|
||||
/// Begins the row's drag out of the trash — an ordinary **card session on the trashed side**,
|
||||
/// which is the whole of what makes it a restore rather than a move (04-interactions.md ▸ The
|
||||
/// trash; `DragLocality.operation`).
|
||||
///
|
||||
/// **Lane entries are not draggable** (03-board-ui.md § Trash: "a lane entry is not draggable —
|
||||
/// its entry is a compact row, not the lane; its move-out is Put Back"), so the drag half is
|
||||
/// simply absent for them and the release still selects.
|
||||
/// Where it lands is the ordinary drop model's answer: `DropSlotMath.cardSlot` over the
|
||||
/// destination lane's masonry, so "restores it at the drop position" is the same arithmetic every
|
||||
/// other card drop uses. What a release *means* differs by locality and modifier, and that lives
|
||||
/// in one place (`BoardDropContext.commitDrop`): within the board a restore, ⌥ a live copy-out,
|
||||
/// across boards a live copy with ⌘ forcing the true restore-move.
|
||||
///
|
||||
/// The coordinate space is the strip's, because what the release needs is a *position* over the
|
||||
/// board, not a translation.
|
||||
private var rowGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 0, coordinateSpace: .named(BoardView.stripSpace))
|
||||
.onChanged { value in
|
||||
guard isDraggable, !store.isReadOnly, !store.isEditingInline else { return }
|
||||
if !dragSession.isDragging(entry.id) {
|
||||
let travelled = max(abs(value.translation.width), abs(value.translation.height))
|
||||
guard travelled > TrashDragSession.threshold else { return }
|
||||
dragSession.begin(cardID: entry.id)
|
||||
}
|
||||
dragSession.update(targetLaneID: drag.laneUnder(value.location.x))
|
||||
/// **Multi-drag carries the whole trashed selection**, in the trash's own sorted order — the
|
||||
/// order the rows are drawn in, which is the only relative order a set of tombstones has.
|
||||
///
|
||||
/// Refused under the read-only lock and while an inline editor is focused, like every other
|
||||
/// mutating gesture.
|
||||
private func startRowDrag() -> NSItemProvider {
|
||||
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
||||
let selection = store.selection
|
||||
let ids: Set<ItemID> = selection.liveness == .trashed
|
||||
&& selection.ids.contains(entry.id)
|
||||
&& selection.ids.count > 1
|
||||
? selection.ids
|
||||
: [entry.id]
|
||||
|
||||
// Card entries only: a lane entry cannot be dragged at all, so one caught up in a mixed
|
||||
// selection is simply not carried. (The selection is homogeneous by kind anyway — this is
|
||||
// belt over braces.)
|
||||
let rows: [(id: ItemID, laneID: ItemID, title: String?)] = TrashModel.entries(of: store.snapshot)
|
||||
.compactMap { candidate in
|
||||
guard ids.contains(candidate.id), case let .card(card, laneID) = candidate else { return nil }
|
||||
return (card.id, laneID, card.title.value)
|
||||
}
|
||||
.onEnded { value in
|
||||
guard dragSession.isDragging(entry.id) else {
|
||||
select()
|
||||
return
|
||||
}
|
||||
dragSession.end()
|
||||
// A drop over anything but a live lane — the trash itself, a gap, the outer margin —
|
||||
// writes nothing. There is no replica to snap back; the row never left.
|
||||
guard let lane = drag.laneUnder(value.location.x) else { return }
|
||||
// m5-drag phase 2: the drop position comes from `DropSlotMath.cardSlot` once this
|
||||
// gesture is replaced by the real drag session. Until then the interim is the
|
||||
// destination lane's bottom, which is the index past its last rendered card.
|
||||
let bottom = store.snapshot.lanes
|
||||
.first { $0.id == lane }?
|
||||
.cards.filter { !$0.isDeleted }.count ?? 0
|
||||
store.restoreByDrag(cardID: entry.id, intoLane: lane, at: bottom)
|
||||
guard !rows.isEmpty else { return NSItemProvider() }
|
||||
|
||||
let root = store.rootURL
|
||||
let payload = DragPayload(
|
||||
boardRoot: root,
|
||||
kind: .cards,
|
||||
side: .trashed,
|
||||
items: rows.map {
|
||||
DragPayload.Item(
|
||||
id: $0.id.rawValue,
|
||||
folder: TrashModel.ItemPath(laneID: $0.laneID, cardID: $0.id).folder(under: root).path,
|
||||
title: $0.title
|
||||
)
|
||||
}
|
||||
)
|
||||
drops.session.beginCards(
|
||||
rows.map(\.id),
|
||||
folders: payload.folders,
|
||||
heights: rows.map { _ in LaneDropRegistry.nominalCardHeight },
|
||||
side: .trashed,
|
||||
source: store
|
||||
)
|
||||
return payload.itemProvider()
|
||||
}
|
||||
|
||||
private var isDraggable: Bool { !entry.isLaneEntry }
|
||||
/// The image under the cursor: the row as it is drawn, fanned with a count badge for a
|
||||
/// multi-drag — the card replica's treatment, at a trash row's size.
|
||||
private var dragReplica: some View {
|
||||
let count = store.selection.liveness == .trashed && store.selection.ids.contains(entry.id)
|
||||
? max(1, store.selection.ids.count)
|
||||
: 1
|
||||
return ZStack {
|
||||
if count > 2 { rowFace.offset(x: 10, y: 10).opacity(0.45) }
|
||||
if count > 1 { rowFace.offset(x: 5, y: 5).opacity(0.7) }
|
||||
rowFace
|
||||
}
|
||||
.frame(width: 200)
|
||||
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
|
||||
.padding(12)
|
||||
}
|
||||
|
||||
// MARK: - The trash entry's context menu
|
||||
|
||||
|
||||
Reference in New Issue
Block a user