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:
2026-07-27 20:58:26 -04:00
parent f2d9f3ad07
commit 90cf82d740
24 changed files with 2307 additions and 739 deletions
+11
View File
@@ -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.
+24
View File
@@ -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>
+120 -34
View File
@@ -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
)
}
}
}
+440
View File
@@ -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
View File
@@ -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))
}
}
+116
View File
@@ -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
}
}
+139
View File
@@ -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
}
}
+418
View File
@@ -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
}
}
}
}
+61
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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? {
-101
View File
@@ -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
}
}
-69
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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`).
///
+1 -1
View File
@@ -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
View File
@@ -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