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:
+7
-3
@@ -2,7 +2,11 @@
|
||||
|
||||
How dragging reorders items on a board. Ported from the pathfinder's document of the same name and rewritten for Lanework's two layouts: the **lane strip** (horizontal, mixed widths via the `width` unit multiplier) and the **card masonry** inside every lane (as many interior columns of standard width as the lane has units, each column stacking independently). The pathfinder wrote its model for columns and kept a "Generalizing to 2D" coda for the day cards could differ in size; in Lanework that day is the first one, so the coda is the present tense and lives inline.
|
||||
|
||||
Implementation: `Kanban/UI/Board/DropSlotMath.swift` (pure zone math), `Kanban/UI/Board/MasonryLayout.swift` (`MasonryPlacement`, the resting grid), `Kanban/UI/Board/DragAutoScrollMath.swift` (edge autoscroll geometry), `Kanban/UI/Board/LaneLayoutMath.swift` (the strip's analytic resting layout), and the store's drop commits in `Kanban/LiveStore/BoardStore.swift` (`moveCards`, `copyCards`, `receiveCards`, `receiveLanes`, `restoreByDrag`, `receiveRestoredCards`). Tests in `KanbanTests/DropSlotMathTests.swift`, `KanbanTests/DragAutoScrollMathTests.swift` and `KanbanTests/DragWriteTests.swift`. The drag session itself — gestures, drop delegates, the travelling replica, the badge — is the second half of the same milestone and is named below wherever this document depends on it.
|
||||
Implementation, in two halves.
|
||||
|
||||
**The arithmetic**: `Kanban/UI/Board/DropSlotMath.swift` (pure zone math), `Kanban/UI/Board/MasonryLayout.swift` (`MasonryPlacement`, the resting grid), `Kanban/UI/Board/DragAutoScrollMath.swift` (edge autoscroll geometry), `Kanban/UI/Board/LaneLayoutMath.swift` (the strip's analytic resting layout), and the store's drop commits in `Kanban/LiveStore/BoardStore.swift` (`moveCards`, `copyCards`, `moveLanes`, `receiveCards`, `receiveLanes`, `restoreByDrag`, `receiveRestoredCards`). Tests in `KanbanTests/DropSlotMathTests.swift`, `KanbanTests/DragAutoScrollMathTests.swift` and `KanbanTests/DragWriteTests.swift`.
|
||||
|
||||
**The session**: `Kanban/UI/Board/DragPayload.swift` (the exported UTTypes and the pasteboard JSON), `Kanban/UI/Board/DragSession.swift` (the app-wide session state, `DragLocality`, the `CommittedHold`, the watchdog), `Kanban/UI/Board/BoardDrops.swift` (`BoardDropContext`'s shared retargets and commit, plus the three drop delegates and the measured-geometry registry), `Kanban/UI/Board/DragAutoScroller.swift` (the ticking driver), and `Kanban/UI/Board/DragShadow.swift` (the shadow and the multi-drag count badge). The gestures live where the design puts the handles — `LaneView` (the header's `.onDrag`, the card face's, and each lane's drop target) and `TrashLaneView` (a row's). Tests in `KanbanTests/DragSessionTests.swift`; the delegates and gestures are deliberately thin over the tested values.
|
||||
|
||||
## The pieces
|
||||
|
||||
@@ -28,7 +32,7 @@ Two stability rules on top:
|
||||
|
||||
## The lane strip's resting layout is arithmetic
|
||||
|
||||
The strip has no scroller and no measured frames worth reading: every lane is always on screen because the window's width divides across the lanes' width units (03-board-ui.md § Layout — full visibility). So the resting layout is a closed-form expression of `standardWidth(stripWidth:totalUnits:gap:)`, `slotWidth(units:standard:gap:)` and the unit counts of the visible lanes *minus the dragged run* — `LaneLayoutMath`'s own arithmetic, reused rather than restated (`DropSlotMath.laneExtents`). The first slot starts at `gap`, because the strip's outer margin is one gap wide, exactly as `LaneReorderMath.centre` already reads it.
|
||||
The strip has no scroller and no measured frames worth reading: every lane is always on screen because the window's width divides across the lanes' width units (03-board-ui.md § Layout — full visibility). So the resting layout is a closed-form expression of `standardWidth(stripWidth:totalUnits:gap:)`, `slotWidth(units:standard:gap:)` and the unit counts of the visible lanes *minus the dragged run* — `LaneLayoutMath`'s own arithmetic, reused rather than restated (`DropSlotMath.laneExtents`). The first slot starts at `gap`, because the strip's outer margin is one gap wide — the same origin `LaneLayoutMath.laneIndex` hit-tests against and the session's cursor conversion lands in.
|
||||
|
||||
`standard` is **not** recomputed with the dragged lanes removed. It is a function of the board's unit total, and a lane in flight is still a lane on the board — the shadow occupies its units. Recomputing would re-divide the whole strip at pickup and again at release, which is the "motion feeds back into logic" failure this model exists to avoid. For a cross-board arrival the destination board's own `standard` is the one that counts, and the arriving run is measured in the destination's units.
|
||||
|
||||
@@ -110,7 +114,7 @@ The commit is the store's, and it is one `performWrite` bracket per gesture what
|
||||
| --- | --- | --- |
|
||||
| Within-board card drag | `moveCards(_:toLane:at:)` | `moveItem` per card — same parent degrades to a rank rewrite |
|
||||
| Within-board ⌥-drag | `copyCards(_:toLane:at:)` | `copyItem` per card, `.fork` stamps |
|
||||
| Within-board lane drag | `moveLane(_:toIndex:)` | `moveItem`, same-parent reorder |
|
||||
| Within-board lane drag | `moveLanes(_:toIndex:)` | `moveItem`, same-parent reorder |
|
||||
| Cross-board cards | `receiveCards(_:operation:toLane:at:)` on the **destination** store | `copyItem` / `moveItem` |
|
||||
| Cross-board lanes | `receiveLanes(_:operation:at:)` on the **destination** store | `copyItem` + tombstone strip / `moveItem` |
|
||||
| Trash → live lane, same board | `restoreByDrag(cardID:intoLane:at:)` | `restoreItem` then `moveItem` |
|
||||
|
||||
@@ -165,6 +165,17 @@ public final class AppModel {
|
||||
/// board-scoped and this list deliberately is not.
|
||||
public let styleRecents = StyleRecents()
|
||||
|
||||
/// The app's one drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
||||
///
|
||||
/// App-wide for the reason cross-board drags exist at all: **a drag crosses windows**, so the
|
||||
/// source board hides the dragged items while any other open board's drop delegates propose a
|
||||
/// landing spot for them. It lives here rather than as a global for `styleRecents`' reason — a
|
||||
/// test holds its own rather than colliding with the app's — and every board window reaches it
|
||||
/// through the environment.
|
||||
/// Internal rather than `public`, unlike its neighbours: the drag is entirely a UI-layer
|
||||
/// concern, and nothing outside this module has any business reaching into a gesture in flight.
|
||||
let dragSession = DragSession()
|
||||
|
||||
// MARK: Sessions
|
||||
|
||||
/// One open board window and everything hanging off it.
|
||||
|
||||
@@ -44,6 +44,30 @@
|
||||
<string>kanban</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<!-- The drag session payload types (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
||||
Same-app transfer is the only real consumer — both boards are open in this app — but a
|
||||
system drag session is what crosses window boundaries, draws the copy badge and gives
|
||||
the full-size replica, and a system session needs a declared type to carry. -->
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>dev.rzen.indie.kanban.cards</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>Lanework Cards</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>dev.rzen.indie.kanban.lanes</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>Lanework Lanes</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
|
||||
@@ -133,6 +133,15 @@ public final class BoardStore {
|
||||
/// path — see the type's doc comment for why. A failed reload leaves it exactly as it was.
|
||||
public private(set) var snapshot: BoardModel
|
||||
|
||||
/// How many snapshots this store has applied, ever — a counter, not a version.
|
||||
///
|
||||
/// It exists for **the committed-overlay hold** (DRAG-REORDER.md § The committed-overlay hold):
|
||||
/// a drop's overlay stands until "the next snapshot application on that store", and *application*
|
||||
/// is the event, not change. A reload that produced an identical model still ends the round trip
|
||||
/// the overlay was covering — comparing `snapshot` values would leave the overlay standing
|
||||
/// exactly when the write turned out to be a no-op.
|
||||
public private(set) var snapshotGeneration: Int = 0
|
||||
|
||||
/// Tolerated anomalies from the load that produced `snapshot` (stray folders, an indexless
|
||||
/// UUID-shaped folder, a board-level `deleted:`). Replaced with the snapshot, so they always
|
||||
/// describe the tree currently on screen.
|
||||
@@ -449,6 +458,7 @@ public final class BoardStore {
|
||||
reduced: Motion.prefersReducedMotion
|
||||
)) {
|
||||
snapshot = result.model
|
||||
snapshotGeneration += 1
|
||||
loadWarnings = result.warnings
|
||||
// The one place transient state is re-grounded. It goes last, after `snapshot` is
|
||||
// the new one, because a view woken by the snapshot's change must never observe a
|
||||
@@ -1133,7 +1143,7 @@ public final class BoardStore {
|
||||
|
||||
/// Commits a lane drag: `id` lands at display position `index` among the board's live lanes,
|
||||
/// counted **with the dragged lane itself removed** — which is the index
|
||||
/// `LaneReorderMath.proposedIndex` produces.
|
||||
/// `DropSlotMath.laneSlot` produces.
|
||||
///
|
||||
/// Within-board only. A cross-board lane drag is the locality model's (04-interactions.md ▸
|
||||
/// Drag and drop) and belongs to m5's drag card; here source and destination board roots are
|
||||
@@ -1177,6 +1187,57 @@ public final class BoardStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// The within-board **lane drag**, multi-drag included: `ids` land contiguously at display
|
||||
/// position `index` among the board's live lanes, counted with the dragged run removed — the
|
||||
/// index `DropSlotMath.laneSlot` produces.
|
||||
///
|
||||
/// `moveLane`'s plural, and it exists rather than a loop over it because "one `performWrite`
|
||||
/// bracket per gesture whatever the set's size" is load-bearing (DRAG-REORDER.md § The drop
|
||||
/// commits): one app-mediated reload, and on git boards one commit rather than N.
|
||||
///
|
||||
/// The run keeps **board order**, which is the lane level's flatten order — a multi-lane drag has
|
||||
/// no other relative order to preserve.
|
||||
///
|
||||
/// A drag that changes nothing writes nothing, stated as the arrangement rather than as a special
|
||||
/// case: if the strip would render exactly what it renders now, no rank is rewritten and no
|
||||
/// commit is minted.
|
||||
public func moveLanes(_ ids: Set<ItemID>, toIndex index: Int) {
|
||||
let lanes = snapshot.lanes.filter { !$0.isDeleted }
|
||||
let members = lanes.filter { ids.contains($0.id) }
|
||||
guard !members.isEmpty else { return }
|
||||
|
||||
let remaining = lanes.filter { !ids.contains($0.id) }
|
||||
let target = min(max(0, index), remaining.count)
|
||||
guard DropSlotMath.applied(lanes.map(\.id), moving: members.map(\.id), to: target) != lanes.map(\.id)
|
||||
else { return }
|
||||
|
||||
let root = rootURL
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var ranks = Ranks.insertionRanks(amongVisible: remaining.map(\.order), at: target, count: members.count)
|
||||
if ranks == nil {
|
||||
// Compact and place again. The dragged lanes *are* among the renumbered children —
|
||||
// they are real folders on disk — so their fresh rungs are dropped from the ladder
|
||||
// before the neighbours are consulted, exactly as `moveLane` drops its one.
|
||||
try BoardWriter.renumberVisibleChildren(of: root)
|
||||
let compacted = zip(lanes, Ranks.renumbered(count: lanes.count))
|
||||
.filter { !ids.contains($0.0.id) }
|
||||
.map(\.1)
|
||||
ranks = Ranks.insertionRanks(amongVisible: compacted, at: target, count: members.count)
|
||||
}
|
||||
guard let ranks else { return }
|
||||
|
||||
for (member, rank) in zip(members, ranks) {
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: root.appendingPathComponent(member.id.rawValue, isDirectory: true),
|
||||
toParent: root,
|
||||
sourceBoardRoot: root,
|
||||
destinationBoardRoot: root,
|
||||
order: rank
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drag & drop commits
|
||||
|
||||
// The writes a released drag performs (04-interactions.md ▸ Drag and drop; the geometry that
|
||||
@@ -1758,42 +1819,66 @@ 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 }
|
||||
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 == cardID })
|
||||
else { return }
|
||||
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 {
|
||||
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 != recordedOrder {
|
||||
if rank != row.card.order {
|
||||
document.set(FrontmatterKeys.order, to: .double(rank))
|
||||
}
|
||||
}
|
||||
return
|
||||
continue
|
||||
}
|
||||
|
||||
try BoardWriter.restoreItem(at: cardFolder)
|
||||
@@ -1806,6 +1891,7 @@ public final class BoardStore {
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection (delegated)
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
+176
-169
@@ -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)
|
||||
ForEach(slots) { slot in
|
||||
switch slot {
|
||||
case let .lane(lane):
|
||||
laneSlot(lane, standard: standard)
|
||||
// "Appear/disappear is scale + fade … lanes ~0.9" (03-board-ui.md § Motion).
|
||||
// A create, a delete and a Put Back all reach the strip as a lane arriving in
|
||||
// or leaving this `ForEach`; whether that *performs* is decided upstream, at
|
||||
// the reload that carried it (`Motion.reloadAnimates`) — a transition with no
|
||||
// animated transaction around it is simply an appearance.
|
||||
.transition(Motion.laneTransition(reduced: reduceMotion))
|
||||
case let .shadow(_, units):
|
||||
// One of the drag's N contiguous shadows, at the exact width the arriving lane
|
||||
// will occupy — its units measured against *this* strip's standard, which is
|
||||
// what makes the drop land precisely where the shadow shows.
|
||||
DragShadow()
|
||||
.frame(width: LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing))
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
if isTrashVisible {
|
||||
// Trailing, always — the quasi-lane has no position of its own to lose, which is
|
||||
// also why it never appears in the reorder proposal's inputs (those are built
|
||||
// from `liveLanes`).
|
||||
// also why it never appears in the drop proposal's inputs (those are built from
|
||||
// `liveLanes`) and why the terminal slot clamps in front of it.
|
||||
TrashLaneView(
|
||||
store: store,
|
||||
confirmations: confirmations,
|
||||
drag: TrashRowDrag { x in laneUnder(x: x, standard: standard) },
|
||||
dragSession: trashDrag,
|
||||
drops: dropContext,
|
||||
marquee: marqueeControl
|
||||
)
|
||||
.frame(width: LaneLayoutMath.slotWidth(units: 1, standard: standard, gap: spacing))
|
||||
@@ -229,15 +256,14 @@ struct BoardView: View {
|
||||
}
|
||||
// The drag's reflow-to-make-room, keyed on the **drop proposal** and nothing else
|
||||
// (03-board-ui.md § Motion: transactions are keyed narrowly, "on the drag's drop
|
||||
// proposal … never on broad state"). The travelling lane's own offset changes on every
|
||||
// pointer sample and none of those samples touch this value, so the replica keeps
|
||||
// tracking the cursor 1:1 — which is the same bullet's other half. At the instant the
|
||||
// proposal ticks, the lane's slot and its offset move by equal and opposite amounts, so
|
||||
// animating both under one curve is what keeps it pinned under the cursor.
|
||||
// proposal … never on broad state"). Narrowed further to the *strip's* proposal: a card
|
||||
// session moving its shadow inside a lane must not re-time the whole strip. The replica's
|
||||
// own tracking is the system drag image's and touches nothing here, which is the same
|
||||
// bullet's other half — animating input echo would be lag.
|
||||
//
|
||||
// It stays on the `HStack` rather than moving out to the `ZStack`, so the marquee band
|
||||
// drawn beside it is never inside an animated transaction (03 § Motion again).
|
||||
.animation(Motion.dragReflow(reduced: reduceMotion), value: move?.to)
|
||||
.animation(Motion.dragReflow(reduced: reduceMotion), value: stripProposal)
|
||||
}
|
||||
|
||||
/// The rubber band itself: a translucent accent fill with a hairline border, in strip
|
||||
@@ -303,22 +329,19 @@ struct BoardView: View {
|
||||
/// The outer frame is always the snapped slot width, so the `HStack` lays the other lanes out
|
||||
/// off the tidy snapped layout regardless of the live overflow.
|
||||
///
|
||||
/// While this lane is being **reordered** the slot instead keeps its resting size and travels:
|
||||
/// the offset is the gap between where the pointer has carried it and where it would rest under
|
||||
/// the current proposal, so it tracks the cursor 1:1 while its siblings sit in the would-be
|
||||
/// order beneath it (`zIndex(2)`, above even a resize).
|
||||
/// A lane being **dragged** is simply absent from the strip: it is lifted out of the resting
|
||||
/// layout at pickup and stays out until release, whatever the effective operation is
|
||||
/// (DRAG-REORDER.md § Resting-layout zones), while the system drag session carries its replica.
|
||||
@ViewBuilder
|
||||
private func laneSlot(_ lane: Lane, at position: Int, among shown: [Lane], standard: CGFloat) -> some View {
|
||||
private func laneSlot(_ lane: Lane, standard: CGFloat) -> some View {
|
||||
let resizing = resize.isResizing(lane.id)
|
||||
let dragging = reorder.isDragging(lane.id)
|
||||
let units = resizing ? resize.units : LaneLayoutMath.displayUnits(of: lane)
|
||||
let slotWidth = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing)
|
||||
ZStack(alignment: .topLeading) {
|
||||
if resizing {
|
||||
LaneResizeShadow()
|
||||
DragShadow(dashed: false)
|
||||
.frame(width: slotWidth)
|
||||
.frame(maxHeight: .infinity)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
// Interior columns follow the SNAPPED unit count while this lane is being resized — a
|
||||
// column count is integral, so it tracks k (which ticks and animates), not the live
|
||||
@@ -329,27 +352,15 @@ struct BoardView: View {
|
||||
store: store,
|
||||
lane: lane,
|
||||
columns: units,
|
||||
reorder: reorder,
|
||||
headerDrag: headerDrag(at: position, among: shown, standard: standard),
|
||||
slotWidth: slotWidth,
|
||||
drops: dropContext,
|
||||
marquee: marqueeControl,
|
||||
openCard: openCard
|
||||
)
|
||||
.frame(width: resizing ? resize.liveWidth : slotWidth, alignment: .leading)
|
||||
}
|
||||
// The drop highlight for a drag out of the trash: the lane the pointer is currently over.
|
||||
// Feedback lives on the *target* rather than on a travelling replica, because the replica —
|
||||
// its lift, its settle, the copy/move badge — is m5's drag card (03-board-ui.md § Motion).
|
||||
.overlay {
|
||||
if trashDrag.isTarget(lane.id) {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.strokeBorder(Color.accentColor, lineWidth: 2)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.frame(width: slotWidth, alignment: .topLeading)
|
||||
.offset(x: dragging ? travelOffset(at: position, among: shown, standard: standard) : 0)
|
||||
.opacity(dragging ? 0.9 : 1)
|
||||
.zIndex(dragging ? 2 : (resizing ? 1 : 0))
|
||||
.zIndex(resizing ? 1 : 0)
|
||||
.overlay(alignment: .trailing) {
|
||||
LaneResizeHandle(
|
||||
store: store,
|
||||
@@ -365,9 +376,10 @@ struct BoardView: View {
|
||||
// resizes the *window* on the way, so a refused commit would leave the window grown
|
||||
// around a lane that snapped back — and the lock's row is already saying why nothing
|
||||
// can be written. The focused-editor rule closes it too, like every board command, and
|
||||
// so does a reorder in flight: two drags mutating one strip layout is not a state this
|
||||
// view has a meaning for.
|
||||
.disabled(store.isReadOnly || store.isEditingInline || reorder.isActive)
|
||||
// so does a drag session in flight — the edge drag "refuses to start while a card/lane
|
||||
// session is in flight" (DRAG-REORDER.md § Adjacent interaction): two gestures mutating
|
||||
// one strip layout is not a state this view has a meaning for.
|
||||
.disabled(store.isReadOnly || store.isEditingInline || appModel.dragSession.isActive)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,90 +399,99 @@ struct BoardView: View {
|
||||
store.transient.isTrashVisible
|
||||
}
|
||||
|
||||
/// The live lane under `x` in strip coordinates, or `nil` — the strip's half of drag-to-restore.
|
||||
///
|
||||
/// Re-derived against `liveLanes` at gesture time rather than captured at drag start, which is
|
||||
/// 04-interactions.md ▸ Drag and drop's re-grounding rule: a foreign reload that adds or
|
||||
/// tombstones a lane mid-drag just moves the zones, and the next proposal targets the board as it
|
||||
/// now is. A tombstoned lane is never a drop target because it is never in this list.
|
||||
private func laneUnder(x: CGFloat, standard: CGFloat) -> ItemID? {
|
||||
let lanes = liveLanes
|
||||
guard let index = LaneLayoutMath.laneIndex(
|
||||
atX: x,
|
||||
unitCounts: unitCounts(of: lanes),
|
||||
standard: standard,
|
||||
gap: spacing
|
||||
) else { return nil }
|
||||
return lanes.indices.contains(index) ? lanes[index].id : nil
|
||||
}
|
||||
// MARK: - The drag
|
||||
|
||||
// MARK: - Reorder
|
||||
|
||||
/// Where the dragged lane sits in `lanes` and where it would land — `nil` when no reorder is in
|
||||
/// flight, or when the lane it is carrying is no longer on the board.
|
||||
/// What each of this window's drop targets — and its lanes' autoscroll drivers — is handed.
|
||||
///
|
||||
/// Called **once** per render, because a proposal is two things at once and they must be the
|
||||
/// same answer: the order the strip shows, and the narrow key its reflow animates on (see
|
||||
/// `body`). It is asked again at release, against the snapshot as it is by then
|
||||
/// (`commitReorder`).
|
||||
private func proposal(among lanes: [Lane], standard: CGFloat) -> (from: Int, to: Int)? {
|
||||
guard let id = reorder.laneID, let from = lanes.firstIndex(where: { $0.id == id }) else { return nil }
|
||||
let to = LaneReorderMath.proposedIndex(
|
||||
unitCounts: unitCounts(of: lanes),
|
||||
draggedIndex: from,
|
||||
dragCentreX: reorder.centre,
|
||||
standard: standard,
|
||||
gap: spacing
|
||||
/// Every geometric input is a **closure**, read at event time (`BoardDropContext`): a captured
|
||||
/// snapshot of the strip's frame or its standard width goes stale the moment the layout animates,
|
||||
/// and two delegates holding different snapshots would flap the proposal between them.
|
||||
private var dropContext: BoardDropContext {
|
||||
BoardDropContext(
|
||||
store: store,
|
||||
session: appModel.dragSession,
|
||||
registry: laneDrops,
|
||||
gap: spacing,
|
||||
window: window,
|
||||
stripFrame: { laneDrops.stripFrame },
|
||||
standard: { standardWidth }
|
||||
)
|
||||
return (from, to)
|
||||
}
|
||||
|
||||
/// How far the travelling lane is drawn from the slot it would rest in — the pointer's position
|
||||
/// minus the proposal's. Zero at the instant a tick lands, growing again as the pointer moves
|
||||
/// on, which is what makes the replica read as *held* rather than as snapping.
|
||||
private func travelOffset(at position: Int, among shown: [Lane], standard: CGFloat) -> CGFloat {
|
||||
reorder.centre - LaneReorderMath.centre(
|
||||
ofLaneAt: position,
|
||||
unitCounts: unitCounts(of: shown),
|
||||
standard: standard,
|
||||
/// The strip's 1× lane width.
|
||||
///
|
||||
/// During a resize session the standard is **frozen** at its drag-start value: the window is
|
||||
/// animating mid-resize, so deriving the standard from the live width would feed that animation
|
||||
/// back into every lane and pulse the whole strip. The window is sized on each tick so this frozen
|
||||
/// value equals what the formula yields once the session ends — the handoff is seamless (see
|
||||
/// `LaneResizeSession`).
|
||||
///
|
||||
/// Otherwise it is the ordinary division, over three contributions:
|
||||
///
|
||||
/// - the **live lanes**' units. A lane in flight is still a lane on the board, so a within-board
|
||||
/// drag does *not* re-divide the strip: "the shadow occupies its units" (DRAG-REORDER.md § The
|
||||
/// lane strip's resting layout is arithmetic). Recomputing at pickup and again at release is
|
||||
/// exactly the motion-feeds-back-into-logic failure the model exists to avoid.
|
||||
/// - the **trash's** one fixed unit, *only while shown* — the whole of "Show/Hide Trash is a
|
||||
/// re-divide trigger" (03-board-ui.md § Trash): the window is never touched, the existing width
|
||||
/// simply divides across one more unit and every lane compresses.
|
||||
/// - a **cross-board lane arrival**'s units while its shadow hovers here, by the same rule read
|
||||
/// from the destination's side: the shadow occupies its units, and the strip has to make room
|
||||
/// for them or the shadow would be drawn at a width the lane will not have.
|
||||
private var standardWidth: CGFloat {
|
||||
if resize.isActive { return resize.standard }
|
||||
var units = LaneLayoutMath.totalUnits(of: liveLanes, trashUnits: isTrashVisible ? 1 : 0)
|
||||
units += arrivingLaneUnits
|
||||
return LaneLayoutMath.standardWidth(
|
||||
stripWidth: laneDrops.stripFrame.width,
|
||||
totalUnits: units,
|
||||
gap: spacing
|
||||
)
|
||||
}
|
||||
|
||||
/// The strip's half of a lane header's drag: where the lane rests now, and what a release means.
|
||||
private func headerDrag(at position: Int, among shown: [Lane], standard: CGFloat) -> LaneHeaderDrag {
|
||||
LaneHeaderDrag(
|
||||
startCentre: {
|
||||
LaneReorderMath.centre(
|
||||
ofLaneAt: position,
|
||||
unitCounts: unitCounts(of: shown),
|
||||
standard: standard,
|
||||
gap: spacing
|
||||
)
|
||||
},
|
||||
commit: { commitReorder(standard: standard) }
|
||||
)
|
||||
/// The units a cross-board lane run would add to this strip while its shadow is proposed here;
|
||||
/// zero for a within-board drag, whose lanes are already counted.
|
||||
private var arrivingLaneUnits: Int {
|
||||
let session = appModel.dragSession
|
||||
guard session.isDraggingLanes,
|
||||
session.stripProposal(onBoardRooted: store.rootURL) != nil,
|
||||
let source = session.sourceRoot,
|
||||
!DragLocality.isSameBoard(source, store.rootURL)
|
||||
else { return 0 }
|
||||
return session.laneUnits.reduce(0, +)
|
||||
}
|
||||
|
||||
/// Releases the drag: re-derive the proposal against the snapshot **as it is now** and write it.
|
||||
///
|
||||
/// Re-deriving rather than trusting the last rendered proposal is 04-interactions.md ▸ Drag and
|
||||
/// drop's re-grounding rule at its most consequential moment: a reload that landed between the
|
||||
/// last render and the release must not be written over. A lane that vanished in that window
|
||||
/// yields no proposal and the release simply cancels — "release with no valid proposal cancels;
|
||||
/// items return, nothing is written".
|
||||
///
|
||||
/// `BoardStore.moveLane` owns the rest, the unchanged-index no-op included.
|
||||
private func commitReorder(standard: CGFloat) {
|
||||
defer { reorder.end() }
|
||||
guard let id = reorder.laneID,
|
||||
let (_, to) = proposal(among: liveLanes, standard: standard)
|
||||
else { return }
|
||||
store.moveLane(id, toIndex: to)
|
||||
/// The strip's current lane-drop proposal — the reflow's narrow animation key, and where the
|
||||
/// shadow run opens.
|
||||
private var stripProposal: Int? {
|
||||
appModel.dragSession.stripProposal(onBoardRooted: store.rootURL)
|
||||
}
|
||||
|
||||
private func unitCounts(of lanes: [Lane]) -> [Int] {
|
||||
lanes.map { LaneLayoutMath.displayUnits(of: $0) }
|
||||
/// One position in the strip: a lane, or one of the drag's N contiguous shadows.
|
||||
private enum StripSlot: Identifiable {
|
||||
case lane(Lane)
|
||||
case shadow(index: Int, units: Int)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .lane(lane): "lane:\(lane.id.rawValue)"
|
||||
// Constant per position, so a shadow run keeps its identity as the proposal slides and
|
||||
// the run animates as a move rather than blinking out and back in.
|
||||
case let .shadow(index, _): "shadow:\(index)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the strip lays out: the resting lanes — the dragged run lifted out, whatever the
|
||||
/// effective operation is — with the shadows opened at the proposal.
|
||||
private var stripSlots: [StripSlot] {
|
||||
let session = appModel.dragSession
|
||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
||||
var slots = liveLanes.filter { !hidden.contains($0.id) }.map(StripSlot.lane)
|
||||
guard let index = stripProposal else { return slots }
|
||||
let shadows = session.laneUnits.enumerated().map { StripSlot.shadow(index: $0.offset, units: $0.element) }
|
||||
slots.insert(contentsOf: shadows, at: min(max(0, index), slots.count))
|
||||
return slots
|
||||
}
|
||||
|
||||
// MARK: - Grammar keys
|
||||
@@ -866,17 +887,3 @@ struct BoardView: View {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Resize shadow
|
||||
|
||||
/// The resting footprint a lane snaps back to, drawn behind the live lane during a resize.
|
||||
///
|
||||
/// Minimal on purpose: 03-board-ui.md's placeholder/drag vocabulary lands with the drag milestone,
|
||||
/// and this is the same shape that card and lane drops will want. Kept here rather than invented
|
||||
/// twice.
|
||||
private struct LaneResizeShadow: View {
|
||||
var body: some View {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(.quaternary.opacity(0.5))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import AppKit
|
||||
import QuartzCore
|
||||
import SwiftUI
|
||||
|
||||
/// Drives edge-autoscroll for one lane's scroll view across one drag session — the *driver* half of
|
||||
/// DRAG-REORDER.md § Edge autoscroll, whose geometry is `DragAutoScrollMath`.
|
||||
///
|
||||
/// Three constraints shape it, and each is a rule from that section:
|
||||
///
|
||||
/// - **The pointer is the physical mouse.** Partly for the general animation-proof reason, but mostly
|
||||
/// because drop callbacks only arrive while the mouse *moves*, and holding still against an edge is
|
||||
/// exactly the gesture that must keep scrolling. A ticking task plus `NSEvent.mouseLocation` needs
|
||||
/// no events at all.
|
||||
/// - **Every scroll step re-resolves the proposal.** The cursor is stationary in the lane's space
|
||||
/// while the *content* moves under it, so without this the shadow would freeze at whatever slot the
|
||||
/// last mouse movement proposed and the drop would land there. `didScroll` is that callback, and it
|
||||
/// goes through the same `BoardDropContext.retargetCards(inLane:)` the lane's drop delegate uses —
|
||||
/// one shared retarget, so the two can never disagree.
|
||||
/// - **Termination is structural.** The driver is owned by a `.task(id:)` keyed on the session, so it
|
||||
/// is cancelled the moment the session ends — and `DragSession`'s watchdog guarantees that flag
|
||||
/// clears no matter how the drag finished.
|
||||
@MainActor
|
||||
final class DragAutoScroller {
|
||||
|
||||
/// A view living inside the scroll view's *content*: its `enclosingScrollView` is the scroller to
|
||||
/// drive, and its window converts the physical cursor. Weak — the view belongs to the hierarchy.
|
||||
fileprivate weak var anchor: NSView?
|
||||
|
||||
/// Invoked after every scroll step. Re-resolves the drop proposal; see the type's note.
|
||||
fileprivate var didScroll: (() -> Void)?
|
||||
|
||||
private var lastTick: CFTimeInterval?
|
||||
|
||||
nonisolated init() {}
|
||||
|
||||
/// Ticks at display rate until the owning task is cancelled.
|
||||
func run() async {
|
||||
lastTick = nil
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .milliseconds(16))
|
||||
guard !Task.isCancelled else { break }
|
||||
step()
|
||||
}
|
||||
lastTick = nil
|
||||
}
|
||||
|
||||
private func step() {
|
||||
let now = CACurrentMediaTime()
|
||||
let elapsed = min(max(now - (lastTick ?? now), 0), 0.05)
|
||||
lastTick = now
|
||||
guard elapsed > 0,
|
||||
let anchor,
|
||||
let window = anchor.window,
|
||||
let scrollView = anchor.enclosingScrollView
|
||||
else { return }
|
||||
|
||||
let clip = scrollView.contentView
|
||||
let visible = clip.bounds
|
||||
guard visible.width > 0, visible.height > 0 else { return }
|
||||
|
||||
// Physical cursor → window → the clip view's (scrolled) coordinates, then relative to the
|
||||
// visible area's top-left corner, which is the space `DragAutoScrollMath` is written in.
|
||||
let inWindow = window.convertPoint(fromScreen: NSEvent.mouseLocation)
|
||||
let inClip = clip.convert(inWindow, from: nil)
|
||||
let pointer = CGPoint(x: inClip.x - visible.minX, y: inClip.y - visible.minY)
|
||||
guard DragAutoScrollMath.engagementRect(viewport: visible.size).contains(pointer) else { return }
|
||||
|
||||
let velocity = DragAutoScrollMath.velocity(pointer: pointer, viewport: visible.size)
|
||||
guard velocity.dx != 0 || velocity.dy != 0 else { return }
|
||||
|
||||
let document = scrollView.documentView?.frame ?? .zero
|
||||
var proposed = visible
|
||||
proposed.origin.x = DragAutoScrollMath.nextOffset(
|
||||
current: visible.minX, velocity: velocity.dx, elapsed: CGFloat(elapsed),
|
||||
minOffset: document.minX, maxOffset: document.maxX - visible.width)
|
||||
proposed.origin.y = DragAutoScrollMath.nextOffset(
|
||||
current: visible.minY, velocity: velocity.dy, elapsed: CGFloat(elapsed),
|
||||
minOffset: document.minY, maxOffset: document.maxY - visible.height)
|
||||
// The clip view has the final say (content insets, magnification).
|
||||
let target = clip.constrainBoundsRect(proposed).origin
|
||||
guard abs(target.x - visible.minX) > 0.01 || abs(target.y - visible.minY) > 0.01 else { return }
|
||||
|
||||
clip.scroll(to: target)
|
||||
scrollView.reflectScrolledClipView(clip)
|
||||
didScroll?()
|
||||
}
|
||||
}
|
||||
|
||||
/// Binds a `DragAutoScroller` to the scroll view it should drive.
|
||||
///
|
||||
/// **Must be placed inside the scroll view's content** (as its `.background`), so
|
||||
/// `enclosingScrollView` resolves; a background on the `ScrollView` itself sits outside the clip view
|
||||
/// and would find nothing.
|
||||
struct DragAutoScrollAnchor: NSViewRepresentable {
|
||||
|
||||
let scroller: DragAutoScroller
|
||||
|
||||
/// Refreshed on every view update, so the callback always closes over the current context rather
|
||||
/// than the one the session started with.
|
||||
let didScroll: () -> Void
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = NSView()
|
||||
bind(view)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ view: NSView, context: Context) {
|
||||
bind(view)
|
||||
}
|
||||
|
||||
private func bind(_ view: NSView) {
|
||||
scroller.anchor = view
|
||||
scroller.didScroll = didScroll
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
// MARK: - The drag types
|
||||
|
||||
extension UTType {
|
||||
|
||||
/// A drag carrying board **cards** — live card faces or trash rows (04-interactions.md ▸ Drag
|
||||
/// and drop, ▸ The trash). Declared as an exported type in `Info.plist`, because a system drag
|
||||
/// session is what crosses window boundaries, shows the copy badge, and gives the full-size
|
||||
/// replica preview (DRAG-REORDER.md § Cross-board sessions).
|
||||
static let laneworkCards = UTType(exportedAs: "dev.rzen.indie.kanban.cards")
|
||||
|
||||
/// A drag carrying board **lanes** — the strip's own reorder and the cross-board lane transfer.
|
||||
static let laneworkLanes = UTType(exportedAs: "dev.rzen.indie.kanban.lanes")
|
||||
}
|
||||
|
||||
// MARK: - What a drag carries
|
||||
|
||||
/// Which of the board's two levels a drag session carries. The selection is homogeneous by kind
|
||||
/// (04-interactions.md § Selection), so a session is one or the other and never both.
|
||||
enum DragKind: String, Codable, Sendable, Equatable {
|
||||
case cards
|
||||
case lanes
|
||||
|
||||
var type: UTType {
|
||||
switch self {
|
||||
case .cards: .laneworkCards
|
||||
case .lanes: .laneworkLanes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The JSON a drag session puts on the pasteboard.
|
||||
///
|
||||
/// **Same-app transfer is the only real consumer.** Both boards are open in this app, so the hover
|
||||
/// path reads identity from `DragSession` — captured synchronously at drag start — and never decodes
|
||||
/// anything mid-flight; the payload is the formal drop data, and what makes the session a *system*
|
||||
/// session at all (which is what crosses windows and draws the badge). It carries folder URLs
|
||||
/// because that is what the destination store's commits take (`receiveCards`, `receiveLanes`), and
|
||||
/// the source board root beside them because locality — the Finder volume model — is a comparison of
|
||||
/// roots (04-interactions.md ▸ Drag and drop).
|
||||
///
|
||||
/// A **plain-text secondary representation** (the dragged titles, newline-joined) rides along so a
|
||||
/// stray drop into a text editor does something sane rather than nothing.
|
||||
struct DragPayload: Codable, Sendable, Equatable {
|
||||
|
||||
/// Which side of the live/tombstoned boundary the drag started on — a trash row's drag is a card
|
||||
/// drag from the trashed side, and 04-interactions.md ▸ The trash gives it its own rules
|
||||
/// (restore within the board, copy-out across boards).
|
||||
enum Side: String, Codable, Sendable, Equatable {
|
||||
case live
|
||||
case trashed
|
||||
|
||||
init(_ liveness: Liveness) {
|
||||
self = liveness == .live ? .live : .trashed
|
||||
}
|
||||
|
||||
var liveness: Liveness { self == .live ? .live : .trashed }
|
||||
}
|
||||
|
||||
/// One dragged item: its UUID, its folder on disk, and its title for the text representation.
|
||||
struct Item: Codable, Sendable, Equatable {
|
||||
var id: String
|
||||
var folder: String
|
||||
var title: String?
|
||||
}
|
||||
|
||||
/// The **source** board's root folder — the left-hand side of the locality comparison.
|
||||
var boardRoot: String
|
||||
|
||||
var kind: DragKind
|
||||
var side: Side
|
||||
|
||||
/// The dragged items **in flatten order** — "lane `order` first, then card `order`"
|
||||
/// (04-interactions.md ▸ Drag and drop). The drop commits trust this order rather than
|
||||
/// re-deriving it, because a cross-board destination has no flatten order for items it does not
|
||||
/// hold.
|
||||
var items: [Item]
|
||||
|
||||
var ids: [ItemID] { items.map { ItemID(rawValue: $0.id) } }
|
||||
|
||||
var folders: [URL] { items.map { URL(fileURLWithPath: $0.folder, isDirectory: true) } }
|
||||
|
||||
var rootURL: URL { URL(fileURLWithPath: boardRoot, isDirectory: true) }
|
||||
|
||||
/// The secondary representation: one title per line, untitled items rendered as they are on the
|
||||
/// board (03-board-ui.md § Card face — "Untitled" is a rendering, never a value).
|
||||
var plainText: String {
|
||||
items.map { $0.title ?? "Untitled" }.joined(separator: "\n")
|
||||
}
|
||||
|
||||
// MARK: Coding
|
||||
|
||||
func encoded() -> Data? {
|
||||
try? JSONEncoder().encode(self)
|
||||
}
|
||||
|
||||
init(boardRoot: URL, kind: DragKind, side: Liveness, items: [Item]) {
|
||||
self.boardRoot = boardRoot.path
|
||||
self.kind = kind
|
||||
self.side = Side(side)
|
||||
self.items = items
|
||||
}
|
||||
|
||||
init?(data: Data) {
|
||||
guard let decoded = try? JSONDecoder().decode(DragPayload.self, from: data) else { return nil }
|
||||
self = decoded
|
||||
}
|
||||
|
||||
/// The item provider a `.onDrag` hands back: the JSON under this session's own type, plus the
|
||||
/// plain-text fallback.
|
||||
///
|
||||
/// The custom type is registered `.ownProcess` deliberately — the payload names folders inside
|
||||
/// the user's boards, and no other app has any business reading it; the *text* is the
|
||||
/// representation other apps get.
|
||||
func itemProvider() -> NSItemProvider {
|
||||
let provider = NSItemProvider()
|
||||
if let data = encoded() {
|
||||
provider.registerDataRepresentation(
|
||||
forTypeIdentifier: kind.type.identifier,
|
||||
visibility: .ownProcess
|
||||
) { completion in
|
||||
completion(data, nil)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
let text = Data(plainText.utf8)
|
||||
provider.registerDataRepresentation(
|
||||
forTypeIdentifier: UTType.utf8PlainText.identifier,
|
||||
visibility: .all
|
||||
) { completion in
|
||||
completion(text, nil)
|
||||
return nil
|
||||
}
|
||||
return provider
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Where a drag would land
|
||||
|
||||
/// The drop proposal: which board, which container, which slot.
|
||||
///
|
||||
/// One value for both layouts, because they differ only in what the container is: `laneID == nil`
|
||||
/// is the **lane strip** (the index counts live lanes with the dragged run removed), and a lane id
|
||||
/// is that lane's **masonry** (the index is a position in its logical card order — DRAG-REORDER.md
|
||||
/// § The card masonry). `boardRoot` is what makes a proposal cross-board-aware: only the board whose
|
||||
/// root it names renders the shadows, and only that board's delegate may commit it.
|
||||
struct DropTarget: Equatable, Sendable {
|
||||
var boardRoot: URL
|
||||
/// `nil` == the lane strip.
|
||||
var laneID: ItemID?
|
||||
var index: Int
|
||||
}
|
||||
|
||||
// MARK: - The committed-overlay hold
|
||||
|
||||
/// The hand-off condition for **the committed-overlay hold** (DRAG-REORDER.md § The
|
||||
/// committed-overlay hold), as a value so the state machine is testable without a filesystem.
|
||||
///
|
||||
/// At release the write goes to disk and the *snapshot does not change* — the one-way flow means the
|
||||
/// board only shows the new order once the watcher's reload lands (02-architecture.md). Dropping the
|
||||
/// drag state at release would snap every sibling back to the pre-drop layout for a frame. So the
|
||||
/// session flips from *proposing* to *committed*, keeps rendering the arrangement it was showing,
|
||||
/// and stands until the destination store applies its next snapshot — **any** snapshot: the
|
||||
/// app-mediated echo is normally next, and a foreign one that lands first re-grounds everything
|
||||
/// anyway.
|
||||
///
|
||||
/// The `deadline` is the same guarantee the drag session's watchdog gives the drag itself: a write
|
||||
/// that was refused outright (a read-only board) produces no reload at all, and an overlay with no
|
||||
/// hand-off coming must still dissolve and let the snapshot be the authority again.
|
||||
struct CommittedHold: Equatable, Sendable {
|
||||
|
||||
/// The board whose next applied snapshot retires this hold.
|
||||
var boardRoot: URL
|
||||
|
||||
/// That board's `snapshotGeneration` at the moment of the commit.
|
||||
var generation: Int
|
||||
|
||||
/// How long the hold may stand with no snapshot arriving. Comfortably longer than a write plus
|
||||
/// a watcher round trip, short enough that a refused write does not leave the board drawing an
|
||||
/// arrangement it never got.
|
||||
static let timeout: Duration = .milliseconds(1500)
|
||||
|
||||
/// Whether a snapshot applied on `root` at `generation` retires this hold. A snapshot on another
|
||||
/// board says nothing about this one, and the *same* generation is the one already on screen at
|
||||
/// the commit.
|
||||
func isRetired(byRoot root: URL, generation: Int) -> Bool {
|
||||
DragLocality.isSameBoard(root, boardRoot) && generation > self.generation
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Locality
|
||||
|
||||
/// **Locality picks the default — the Finder volume model** (04-interactions.md ▸ Drag and drop,
|
||||
/// settled), as pure functions (`DragLocalityTests`).
|
||||
///
|
||||
/// Within a board a drag is a **move**: rearranging. Between boards it is a **copy**: transferring,
|
||||
/// with the system copy badge showing over the foreign board. **⌥ always forces copy** and **⌘
|
||||
/// always forces move** — Finder's exact modifier grammar — and each is a no-op where its behavior
|
||||
/// is already the default. The operation is therefore a function of (source board, board under the
|
||||
/// cursor, modifiers) sampled every frame, not a decision taken at pickup, which is what lets the
|
||||
/// badge track live as the cursor crosses a boundary.
|
||||
enum DragLocality {
|
||||
|
||||
/// Whether two roots name the same board. Symlinks are resolved first — a board reached through
|
||||
/// a pinned symlink is the same board as the one reached directly (01-storage-format.md's
|
||||
/// symlink pins) — and the standardized path is the comparison key.
|
||||
static func isSameBoard(_ lhs: URL, _ rhs: URL) -> Bool {
|
||||
lhs.resolvingSymlinksInPath().standardizedFileURL.path
|
||||
== rhs.resolvingSymlinksInPath().standardizedFileURL.path
|
||||
}
|
||||
|
||||
/// The effective operation, live.
|
||||
///
|
||||
/// Two carve-outs, both 04-interactions.md's:
|
||||
///
|
||||
/// - **Lane drags never copy within their board.** ⌥ is simply ignored there: the drag stays a
|
||||
/// clean reorder and the badge never shows copy. The within-board lane duplicate exists, but
|
||||
/// its home is the clipboard (▸ Clipboard, Lane paste).
|
||||
/// - **A trash row's drag is copy-out grammar** (▸ The trash). Within its own board it is the
|
||||
/// restore — a move, no badge; across boards the default is the live copy that leaves the
|
||||
/// tombstoned original in place, exactly as ⌘C out of the trash behaves. ⌘ forces the true
|
||||
/// restore-move either way, and ⌥ forces the live copy either way ("⌘C, ⌥-drag, and the
|
||||
/// cross-board drag default always yield *live* copies").
|
||||
static func operation(
|
||||
kind: DragKind,
|
||||
side: Liveness,
|
||||
isWithinBoard: Bool,
|
||||
modifiers: NSEvent.ModifierFlags
|
||||
) -> TransferOperation {
|
||||
let forcesCopy = modifiers.contains(.option)
|
||||
let forcesMove = modifiers.contains(.command)
|
||||
|
||||
// The lane carve-out comes first, because it is an outright refusal of the modifier rather
|
||||
// than a different default: a within-board lane drag is a reorder and nothing else.
|
||||
if kind == .lanes, isWithinBoard { return .move }
|
||||
|
||||
if forcesMove { return .move }
|
||||
if forcesCopy { return .copy }
|
||||
_ = side // the side changes which commit runs, never which operation the badge shows
|
||||
return isWithinBoard ? .move : .copy
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DragSession
|
||||
|
||||
/// The app's one drag session — what is in flight, where it would land, and what a release means
|
||||
/// (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
||||
///
|
||||
/// **App-wide, because a drag crosses windows.** The identities are UUIDs and the folders are paths,
|
||||
/// so the source board hides the dragged items while *any* open board's drop delegates propose a
|
||||
/// landing spot for them. It is owned by `AppModel` and reached through the environment; the system
|
||||
/// `NSItemProvider` payload is the formal drop data (`DragPayload`), and this is what every hover
|
||||
/// actually reads — decoding a provider is asynchronous, and `dropUpdated` must answer synchronously
|
||||
/// to drive the reflow.
|
||||
///
|
||||
/// ### What is frozen and what is not
|
||||
///
|
||||
/// 03-board-ui.md § Motion's animation-proof rule, made structural: the **only** inputs frozen at
|
||||
/// drag start are the *dragged items'* own sizes — card heights and lane unit counts, which the
|
||||
/// pickup transition corrupts the instant it starts. Everything else (the resting zones, the
|
||||
/// destination's standard width, which lanes are live) is recomputed from the current snapshot on
|
||||
/// every sample, which is rule 1 of the mid-drag re-grounding trio: a foreign reload just moves the
|
||||
/// zones.
|
||||
///
|
||||
/// ### Termination is structural
|
||||
///
|
||||
/// `begin` arms a watchdog that polls the physical mouse-button state and clears the session shortly
|
||||
/// after the button is released, no matter where the drop landed — on a delegate (whose
|
||||
/// `performDrop` already cleaned up, making the watchdog a no-op), on empty window space, in another
|
||||
/// window, outside every window, or on a cancelled drag. This is the pathfinder's hard-won lifecycle
|
||||
/// pair: cleanup on session-phase events must be gated on the button being physically up (a finished
|
||||
/// session's events can arrive *after* the next drag has started), and the watchdog is the
|
||||
/// guaranteed path for the sessions SwiftUI never reports at all.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class DragSession {
|
||||
|
||||
// MARK: What is in flight
|
||||
|
||||
/// Which level is being dragged; `nil` when no session is in flight — which is what "no drag"
|
||||
/// means here rather than a separate flag.
|
||||
private(set) var kind: DragKind?
|
||||
|
||||
/// The side the drag started on. A trash row's drag is a `.cards` session on the `.trashed`
|
||||
/// side, and that is the whole of what makes it one (04-interactions.md ▸ The trash).
|
||||
private(set) var side: Liveness = .live
|
||||
|
||||
/// The dragged items in **flatten order** — the order they will land in.
|
||||
private(set) var members: [ItemID] = []
|
||||
|
||||
/// Fast identity test for hiding the originals wherever they render.
|
||||
private(set) var memberSet: Set<ItemID> = []
|
||||
|
||||
/// The dragged items' folders, aligned 1:1 with `members` — what the cross-board commits take.
|
||||
@ObservationIgnored private(set) var folders: [URL] = []
|
||||
|
||||
/// The board the drag started in. Root and store are kept separately because the store may go
|
||||
/// away with its window mid-drag while the root — the left-hand side of the locality
|
||||
/// comparison — stays perfectly usable.
|
||||
private(set) var sourceRoot: URL?
|
||||
|
||||
@ObservationIgnored private(set) weak var sourceStore: BoardStore?
|
||||
|
||||
/// The dragged cards' heights, **frozen at drag start**. `DropSlotMath.cardSlot` caps the
|
||||
/// vertical trigger region at the first one (the shadow the cursor is over), and the shadows are
|
||||
/// drawn at all of them.
|
||||
@ObservationIgnored private(set) var cardHeights: [CGFloat] = []
|
||||
|
||||
/// The dragged lanes' width units, frozen at drag start — the run's span, measured against
|
||||
/// whichever board's standard width it is being proposed into.
|
||||
@ObservationIgnored private(set) var laneUnits: [Int] = []
|
||||
|
||||
// MARK: Where it would land
|
||||
|
||||
/// The current proposal, or `nil` when the drag has none — a fresh session before the first
|
||||
/// sample, or one whose target lane was tombstoned in a reload (rule 2 of the re-grounding
|
||||
/// trio). **Release with no valid proposal cancels.**
|
||||
private(set) var proposal: DropTarget?
|
||||
|
||||
/// The effective operation, re-resolved on every `dropUpdated` so the system badge tracks live.
|
||||
private(set) var operation: TransferOperation = .move
|
||||
|
||||
/// The committed-overlay hold, or `nil` while the session is still proposing. See
|
||||
/// `CommittedHold`.
|
||||
private(set) var hold: CommittedHold?
|
||||
|
||||
@ObservationIgnored private var watchdog: Task<Void, Never>?
|
||||
@ObservationIgnored private var holdTimeout: Task<Void, Never>?
|
||||
|
||||
init() {}
|
||||
|
||||
// MARK: Queries
|
||||
|
||||
var isActive: Bool { kind != nil }
|
||||
var isDraggingCards: Bool { kind == .cards }
|
||||
var isDraggingLanes: Bool { kind == .lanes }
|
||||
|
||||
/// N — the number of contiguous shadows the proposal draws.
|
||||
var shadowCount: Int { members.count }
|
||||
|
||||
/// Whether `id` is one of the dragged items.
|
||||
func isDragging(_ id: ItemID) -> Bool { memberSet.contains(id) }
|
||||
|
||||
/// The items to **leave out of the resting layout** on the board rooted at `root`.
|
||||
///
|
||||
/// Only the source board hides anything, and only for a live-side session: a trash row's drag
|
||||
/// carries items that render in the quasi-lane, not in any lane's masonry, so no lane loses a
|
||||
/// card to it.
|
||||
///
|
||||
/// **The dragged run is lifted out whatever the effective operation is** (DRAG-REORDER.md §
|
||||
/// Resting-layout zones): ⌥ can be pressed and released mid-drag, and a layout that re-admitted
|
||||
/// the originals on every modifier flip would flap the whole board under the cursor. The copy's
|
||||
/// originals reappear when the write lands.
|
||||
func hiddenMembers(onBoardRooted root: URL) -> Set<ItemID> {
|
||||
guard isActive, side == .live, let sourceRoot,
|
||||
DragLocality.isSameBoard(root, sourceRoot)
|
||||
else { return [] }
|
||||
return memberSet
|
||||
}
|
||||
|
||||
/// The proposal's index when it names this board's strip, else `nil` — the lane strip's shadow
|
||||
/// run position.
|
||||
func stripProposal(onBoardRooted root: URL) -> Int? {
|
||||
guard kind == .lanes, let proposal, proposal.laneID == nil,
|
||||
DragLocality.isSameBoard(proposal.boardRoot, root)
|
||||
else { return nil }
|
||||
return proposal.index
|
||||
}
|
||||
|
||||
/// The proposal's index when it names `laneID` on this board, else `nil` — the masonry's shadow
|
||||
/// run position, in the lane's logical card order.
|
||||
func laneProposal(onBoardRooted root: URL, laneID: ItemID) -> Int? {
|
||||
guard kind == .cards, let proposal, proposal.laneID == laneID,
|
||||
DragLocality.isSameBoard(proposal.boardRoot, root)
|
||||
else { return nil }
|
||||
return proposal.index
|
||||
}
|
||||
|
||||
/// The members that are still there — **rule 3 of the re-grounding trio**: drag membership is a
|
||||
/// UUID set that vanished items leave silently (`TransientBoardState.dragMembers`), and when the
|
||||
/// last one goes the drag has emptied itself. Partial vanishing drops the survivors, matching
|
||||
/// the pending-cut precedent.
|
||||
///
|
||||
/// The source store answers because it is the one re-grounding the set against each reload; with
|
||||
/// its window gone there is nothing left to invalidate the set, and the folders on disk are as
|
||||
/// good as they were.
|
||||
var survivors: [Int] {
|
||||
guard let store = sourceStore else { return Array(members.indices) }
|
||||
let live = store.transient.dragMembers.ids
|
||||
return members.indices.filter { live.contains(members[$0]) }
|
||||
}
|
||||
|
||||
// MARK: Lifecycle
|
||||
|
||||
/// Begins a card session — live faces or trash rows.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - members: the dragged cards in flatten order (`SelectionGrammar.liveCards`, or the trash's
|
||||
/// own sorted order for a trash-row drag).
|
||||
/// - heights: their measured heights, captured **before** the pickup transition starts.
|
||||
func beginCards(
|
||||
_ members: [ItemID],
|
||||
folders: [URL],
|
||||
heights: [CGFloat],
|
||||
side: Liveness,
|
||||
source: BoardStore
|
||||
) {
|
||||
begin(kind: .cards, members: members, folders: folders, side: side, source: source)
|
||||
cardHeights = heights
|
||||
laneUnits = []
|
||||
}
|
||||
|
||||
/// Begins a lane session.
|
||||
func beginLanes(_ members: [ItemID], folders: [URL], units: [Int], source: BoardStore) {
|
||||
begin(kind: .lanes, members: members, folders: folders, side: .live, source: source)
|
||||
laneUnits = units
|
||||
cardHeights = []
|
||||
}
|
||||
|
||||
private func begin(
|
||||
kind: DragKind,
|
||||
members: [ItemID],
|
||||
folders: [URL],
|
||||
side: Liveness,
|
||||
source: BoardStore
|
||||
) {
|
||||
endHold()
|
||||
self.kind = kind
|
||||
self.members = members
|
||||
self.memberSet = Set(members)
|
||||
self.folders = folders
|
||||
self.side = side
|
||||
self.sourceStore = source
|
||||
self.sourceRoot = source.rootURL
|
||||
self.proposal = nil
|
||||
self.operation = .move
|
||||
// The reload-resolved drag set: vanished members leave it silently, which is what
|
||||
// `survivors` reads and what "an emptied drag cancels itself" is stated in terms of.
|
||||
source.transient.dragMembers = ItemReferenceSet(ids: memberSet, liveness: side)
|
||||
armWatchdog()
|
||||
}
|
||||
|
||||
/// Records a new proposal. `nil` withdraws it — rule 2's "the shadow withdraws".
|
||||
func propose(_ target: DropTarget?) {
|
||||
guard isActive, proposal != target else { return }
|
||||
proposal = target
|
||||
}
|
||||
|
||||
/// Re-resolves the effective operation against the board under the cursor and the modifiers
|
||||
/// **right now**, and hands it back for the `DropProposal` the badge tracks.
|
||||
@discardableResult
|
||||
func resolveOperation(destinationRoot: URL) -> TransferOperation {
|
||||
guard let kind, let sourceRoot else { return operation }
|
||||
let resolved = DragLocality.operation(
|
||||
kind: kind,
|
||||
side: side,
|
||||
isWithinBoard: DragLocality.isSameBoard(sourceRoot, destinationRoot),
|
||||
modifiers: NSEvent.modifierFlags
|
||||
)
|
||||
if resolved != operation { operation = resolved }
|
||||
return resolved
|
||||
}
|
||||
|
||||
/// Ends the session outright — a cancel, a release with no valid proposal, or the watchdog.
|
||||
func end() {
|
||||
sourceStore?.transient.dragMembers = .empty
|
||||
kind = nil
|
||||
members = []
|
||||
memberSet = []
|
||||
folders = []
|
||||
cardHeights = []
|
||||
laneUnits = []
|
||||
proposal = nil
|
||||
operation = .move
|
||||
sourceStore = nil
|
||||
sourceRoot = nil
|
||||
watchdog?.cancel()
|
||||
watchdog = nil
|
||||
endHold()
|
||||
}
|
||||
|
||||
/// Enters the committed phase: the arrangement the session was showing stays on screen until
|
||||
/// `store` applies its next snapshot (`CommittedHold`).
|
||||
///
|
||||
/// Everything that drives the rendering — the members, the proposal, the source root — is kept
|
||||
/// exactly as it was, so "keeps rendering the arrangement it was showing" needs no second
|
||||
/// mechanism: the shadows stay at the landing slots and the originals stay lifted out until the
|
||||
/// snapshot carrying the write arrives and the real faces take their place.
|
||||
func commit(into store: BoardStore) {
|
||||
guard isActive else { return }
|
||||
sourceStore?.transient.dragMembers = .empty
|
||||
watchdog?.cancel()
|
||||
watchdog = nil
|
||||
let hold = CommittedHold(boardRoot: store.rootURL, generation: store.snapshotGeneration)
|
||||
self.hold = hold
|
||||
holdTimeout?.cancel()
|
||||
holdTimeout = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(for: CommittedHold.timeout)
|
||||
guard !Task.isCancelled, let self, self.hold == hold else { return }
|
||||
withAnimation(Motion.dragReflow(reduced: Motion.prefersReducedMotion)) { self.end() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The hand-off: a snapshot landed on `root`, so an overlay standing in for it dissolves.
|
||||
///
|
||||
/// Called from every board window's own snapshot-generation watch, which is why the hold names
|
||||
/// the board it belongs to — a reload on some other board says nothing about this one.
|
||||
func handOff(root: URL, generation: Int) {
|
||||
guard let hold, hold.isRetired(byRoot: root, generation: generation) else { return }
|
||||
end()
|
||||
}
|
||||
|
||||
private func endHold() {
|
||||
hold = nil
|
||||
holdTimeout?.cancel()
|
||||
holdTimeout = nil
|
||||
}
|
||||
|
||||
/// Cleanup for a session-phase event SwiftUI reports.
|
||||
///
|
||||
/// **Gated on the physical button being up**, because a finished session's `.ended` /
|
||||
/// `.dataTransferCompleted` events can be delivered *after the user has already started the next
|
||||
/// drag*, and a naive handler wipes the new session's state — no shadow, drop dead. The watchdog
|
||||
/// covers every genuinely-ended session anyway, so refusing here costs nothing.
|
||||
func endIfButtonReleased() {
|
||||
guard NSEvent.pressedMouseButtons == 0 else { return }
|
||||
guard isActive, hold == nil else { return }
|
||||
withAnimation(Motion.dragReflow(reduced: Motion.prefersReducedMotion)) { end() }
|
||||
}
|
||||
|
||||
// MARK: The watchdog — the single guaranteed termination path
|
||||
|
||||
/// Polls the physical mouse-button state while a drag is in flight. When the button is released
|
||||
/// and, after a short grace period (long enough for a landing `performDrop` to run first), a
|
||||
/// session still lingers, it is cleared here. Backstops every path a drop delegate cannot see.
|
||||
private func armWatchdog() {
|
||||
watchdog?.cancel()
|
||||
watchdog = Task { @MainActor [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .milliseconds(120))
|
||||
guard let self, self.isActive, self.hold == nil else { return }
|
||||
guard NSEvent.pressedMouseButtons == 0 else { continue }
|
||||
try? await Task.sleep(for: .milliseconds(250))
|
||||
guard !Task.isCancelled, self.isActive, self.hold == nil else { return }
|
||||
withAnimation(Motion.dragReflow(reduced: Motion.prefersReducedMotion)) { self.end() }
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The **shadow placeholder** — the outline occupying an item's proposed landing spot
|
||||
/// (DRAG-REORDER.md § The pieces).
|
||||
///
|
||||
/// One shape for both the drag's shadows and the lane resize's resting footprint, because they are
|
||||
/// the same thing said twice: "here is the space this item occupies in the layout". They differ only
|
||||
/// in whether the space is a *proposal* — the drag's is dashed, because a dashed outline is what
|
||||
/// 03-board-ui.md's placeholder vocabulary asks for and what makes "the drop lands exactly here"
|
||||
/// legible; the resize's is a plain fill, because the lane is already there.
|
||||
///
|
||||
/// **Hit-transparent, always.** The strip's own drop target has to stay live beneath the shadows
|
||||
/// (DRAG-REORDER.md § Single-target dispatch: "shadow placeholders are hit-transparent, so the strip
|
||||
/// target stays live beneath them"), and a shadow that swallowed the release would strand the drop.
|
||||
struct DragShadow: View {
|
||||
|
||||
/// Matched to the surface it stands in for — a lane's plate is 10, a card's is 8.
|
||||
var cornerRadius: CGFloat = 10
|
||||
|
||||
/// A drop proposal (dashed) or a resting footprint (plain).
|
||||
var dashed: Bool = true
|
||||
|
||||
var body: some View {
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(.quaternary.opacity(0.5))
|
||||
.overlay {
|
||||
if dashed {
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.strokeBorder(
|
||||
Color.accentColor.opacity(0.55),
|
||||
style: StrokeStyle(lineWidth: 1.5, dash: [6])
|
||||
)
|
||||
}
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// The count badge a multi-drag's replica wears — Finder's own affordance for "this many are coming
|
||||
/// with me" (04-interactions.md ▸ Drag and drop: "dragging any member of a multi-selection drags the
|
||||
/// whole selection").
|
||||
///
|
||||
/// Draws nothing for a single item, so every replica can carry it unconditionally.
|
||||
struct DragCountBadge: View {
|
||||
|
||||
let count: Int
|
||||
|
||||
var body: some View {
|
||||
if count > 1 {
|
||||
Text("\(count)")
|
||||
.font(.caption2.bold())
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(Circle().fill(Color.accentColor))
|
||||
.offset(x: 10, y: -10)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ enum DropSlotMath {
|
||||
///
|
||||
/// The strip's outer margin is one `gap`, so the first slot starts at `gap`; each lane is
|
||||
/// `LaneLayoutMath.slotWidth(units:standard:gap:)` wide and one `gap` follows it. Same
|
||||
/// arithmetic `LaneReorderMath.centre` walks, in range form.
|
||||
/// arithmetic `LaneLayoutMath.laneIndex` walks, in range form.
|
||||
///
|
||||
/// `unitCounts` is the **visible lanes minus the dragged run**. `standard` is *not* recomputed
|
||||
/// for that shorter list: it is a function of the board's unit total, and a lane in flight is
|
||||
|
||||
@@ -93,7 +93,7 @@ enum LaneLayoutMath {
|
||||
/// "a drop anywhere else is a no-op" (03-board-ui.md § Trash): a drop that does not land
|
||||
/// squarely on a live lane writes nothing rather than guessing at the nearest one.
|
||||
///
|
||||
/// Same analytic geometry as `LaneReorderMath.proposedIndex` — resting positions computed from
|
||||
/// Same analytic geometry as `DropSlotMath.laneExtents` — resting positions computed from
|
||||
/// the unit counts, never measured frames (03-board-ui.md § Motion, "motion never feeds back
|
||||
/// into logic").
|
||||
static func laneIndex(atX x: CGFloat, unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> Int? {
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import CoreGraphics
|
||||
|
||||
/// The lane-reorder drag's geometry, as pure arithmetic — no view, no session, no snapshot
|
||||
/// (`LaneReorderMathTests`). `LaneLayoutMath`'s sibling: that one owns the resting layout and the
|
||||
/// right-edge resize, this one owns "where would the lane land if I let go now".
|
||||
///
|
||||
/// **Geometry-based, so the proposal is stable rather than jittery** (04-interactions.md ▸ Drag and
|
||||
/// drop): the answer is a function of analytically computed resting positions and one pointer
|
||||
/// coordinate — never of measured mid-flight frames, which are garbage precisely during the reflow
|
||||
/// they trigger (03-board-ui.md § Motion, "Motion never feeds back into logic").
|
||||
///
|
||||
/// **Width-aware by construction.** The design asks for "no reflow until the cursor reaches where
|
||||
/// the dragged lane would actually land"; comparing against each remaining lane's *centre* is
|
||||
/// exactly that — a 3× lane's centre is three units along, so the drag has to travel most of that
|
||||
/// lane's width before the board proposes stepping past it, and a 1× lane yields quickly.
|
||||
///
|
||||
/// ### What this deliberately is not
|
||||
///
|
||||
/// The full drag model — the shadow's hold-until-a-new-candidate rule, multi-drag's N contiguous
|
||||
/// shadows, cross-board locality with its copy/move badge, and the mid-drag re-grounding rules — is
|
||||
/// **m5's drag card**, which replaces this file's callers with the real `DropSlot`
|
||||
/// (02-architecture.md § Layering ▸ Components). What is here is the within-board single-lane case
|
||||
/// and nothing else, deliberately small enough to be obviously correct.
|
||||
enum LaneReorderMath {
|
||||
|
||||
/// Where the dragged lane would land: an index into the ordered live lanes **with the dragged
|
||||
/// lane removed**, so the result is in `0...(unitCounts.count - 1)` and `draggedIndex` itself
|
||||
/// means "back where it started".
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - unitCounts: the ordered live lanes' display units (`LaneLayoutMath.displayUnits`), the
|
||||
/// board as it currently is — recomputed against each snapshot rather than frozen at drag
|
||||
/// start, so a foreign lane add or tombstone mid-drag just moves the zones and the next
|
||||
/// proposal targets the board as it now is (04 ▸ Drag and drop, rule 1).
|
||||
/// - draggedIndex: the dragged lane's position in `unitCounts`.
|
||||
/// - dragCentreX: the dragged lane's centre under the cursor, in strip coordinates (0 at the
|
||||
/// strip's leading edge, outer margin included).
|
||||
/// - standard: the 1× lane width (`LaneLayoutMath.standardWidth`).
|
||||
/// - gap: the inter-lane gap, which is also the strip's outer margin.
|
||||
///
|
||||
/// Out-of-range `draggedIndex` yields `0` rather than trapping: the lane vanished under the
|
||||
/// drag, and the caller's release-with-no-valid-proposal rule cancels anyway.
|
||||
static func proposedIndex(
|
||||
unitCounts: [Int],
|
||||
draggedIndex: Int,
|
||||
dragCentreX: CGFloat,
|
||||
standard: CGFloat,
|
||||
gap: CGFloat
|
||||
) -> Int {
|
||||
guard unitCounts.indices.contains(draggedIndex) else { return 0 }
|
||||
|
||||
var remaining = unitCounts
|
||||
remaining.remove(at: draggedIndex)
|
||||
|
||||
// The remaining lanes' resting centres, left to right, in the layout they would have with
|
||||
// the dragged lane gone — which is the layout the siblings are already showing.
|
||||
// Monotonically increasing, so "how many centres has the cursor passed" is both the answer
|
||||
// and the reason it never oscillates: one threshold per slot, crossed once.
|
||||
var index = 0
|
||||
var x = gap
|
||||
for units in remaining {
|
||||
let width = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap)
|
||||
guard dragCentreX > x + width / 2 else { break }
|
||||
index += 1
|
||||
x += width + gap
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
/// The resting centre of the lane at `index` in a strip of `unitCounts`, in the same strip
|
||||
/// coordinates `proposedIndex` reads.
|
||||
///
|
||||
/// Two callers, and they are the two halves of the drag: the gesture freezes this at drag start
|
||||
/// as the origin its translation is measured from (the *physical pointer* being the only live
|
||||
/// input — 03 § Motion), and the view offsets the travelling lane from the centre it would rest
|
||||
/// at under the current proposal, which is what makes the replica track the cursor while the
|
||||
/// siblings sit in their would-be order.
|
||||
static func centre(ofLaneAt index: Int, unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> CGFloat {
|
||||
var x = gap
|
||||
for (position, units) in unitCounts.enumerated() {
|
||||
let width = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap)
|
||||
if position == index { return x + width / 2 }
|
||||
x += width + gap
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
/// `unitCounts` (or any per-lane values) with the item at `from` moved to `to`, where `to` is
|
||||
/// counted **with the item already removed** — the ordering `proposedIndex` returns, applied.
|
||||
///
|
||||
/// Shared by the view (which reorders the lanes it lays out, so the siblings show the would-be
|
||||
/// order) and by the drag's own centre arithmetic, so the two can never disagree about what the
|
||||
/// proposal means.
|
||||
static func reordered<T>(_ items: [T], from: Int, to: Int) -> [T] {
|
||||
guard items.indices.contains(from) else { return items }
|
||||
var result = items
|
||||
let item = result.remove(at: from)
|
||||
result.insert(item, at: min(max(0, to), result.count))
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import CoreGraphics
|
||||
import Observation
|
||||
|
||||
/// Window-local state for an in-flight lane reorder — the drag surface being the whole title bar
|
||||
/// (03-board-ui.md § Lane, "no separate grip"). At most one runs per board window; `BoardView` owns
|
||||
/// it as `@State` and hands it to the lanes.
|
||||
///
|
||||
/// `LaneResizeSession`'s sibling, and deliberately much smaller. It holds only what the *pointer*
|
||||
/// contributes — which lane, how far it has travelled, and the centre it started from — because
|
||||
/// everything else the proposal needs is read fresh from the snapshot at render time
|
||||
/// (`LaneReorderMath.proposedIndex`). That split is 04-interactions.md ▸ Drag and drop's
|
||||
/// re-grounding rule made structural: "the frozen-at-drag-start inputs are the *dragged items'*
|
||||
/// sizes and the physical pointer only … the analytic resting zones recompute against each new
|
||||
/// snapshot", so a foreign lane add mid-drag cannot leave this session holding a stale board.
|
||||
///
|
||||
/// ### The click-versus-drag split
|
||||
///
|
||||
/// A plain click on the title bar selects the lane; only movement past `threshold` begins a
|
||||
/// reorder (04 ▸ Selection: "the drag surface engages only on movement — the click-vs-drag split
|
||||
/// cards already have"). One gesture recognises both, so a hesitant click can never start a drag
|
||||
/// and a drag can never also select.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class LaneReorderSession {
|
||||
|
||||
/// The lane being dragged; `nil` when idle. Observed — flipping it drives the travelling lane's
|
||||
/// z-order and offset, and the siblings' reflow into the proposed order.
|
||||
private(set) var laneID: ItemID?
|
||||
|
||||
/// How far the pointer has travelled horizontally since the drag began. The *only* live input:
|
||||
/// vertical movement is ignored outright, since lanes reorder along one axis.
|
||||
private(set) var translation: CGFloat = 0
|
||||
|
||||
/// The dragged lane's resting centre at drag start, in strip coordinates — the origin
|
||||
/// `translation` is measured from, frozen exactly as 03-board-ui.md § Motion requires.
|
||||
@ObservationIgnored private(set) var startCentre: CGFloat = 0
|
||||
|
||||
/// How far the pointer must move before a click becomes a drag. Small enough that a deliberate
|
||||
/// drag feels immediate, large enough that the tremor in a click never reorders the board.
|
||||
static let threshold: CGFloat = 4
|
||||
|
||||
var isActive: Bool { laneID != nil }
|
||||
|
||||
func isDragging(_ id: ItemID) -> Bool { laneID == id }
|
||||
|
||||
/// The dragged lane's centre under the cursor: the frozen start plus the physical translation,
|
||||
/// and nothing measured.
|
||||
var centre: CGFloat { startCentre + translation }
|
||||
|
||||
/// Begins a reorder of `laneID`, freezing the centre its travel is measured from.
|
||||
func begin(laneID: ItemID, startCentre: CGFloat) {
|
||||
self.laneID = laneID
|
||||
self.startCentre = startCentre
|
||||
self.translation = 0
|
||||
}
|
||||
|
||||
func update(translation: CGFloat) {
|
||||
guard isActive else { return }
|
||||
self.translation = translation
|
||||
}
|
||||
|
||||
/// Ends the drag, handing the caller nothing: the *commit* needs the current snapshot's lane
|
||||
/// order, which `BoardView` has and this session deliberately does not. Idempotent, because a
|
||||
/// gesture can end after the lane it was carrying has already vanished.
|
||||
func end() {
|
||||
laneID = nil
|
||||
translation = 0
|
||||
}
|
||||
}
|
||||
+332
-65
@@ -1,24 +1,6 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - The strip's half of the header drag
|
||||
|
||||
/// What the strip lends a lane so its **whole title bar** can be the drag surface (03-board-ui.md §
|
||||
/// Lane, "no separate grip").
|
||||
///
|
||||
/// The gesture lives on the header — that is where the design puts it — but the two things it needs
|
||||
/// are the strip's: where this lane currently rests (the origin the translation is measured from)
|
||||
/// and what a release means (a proposal computed against the live lane order, then a write). Both
|
||||
/// arrive as closures rather than as values because both must be read at *gesture* time, not at
|
||||
/// body-evaluation time.
|
||||
@MainActor
|
||||
struct LaneHeaderDrag {
|
||||
/// This lane's resting centre in strip coordinates, read the instant the drag begins and frozen
|
||||
/// for its duration (`LaneReorderSession.startCentre`).
|
||||
let startCentre: () -> CGFloat
|
||||
/// Commit the reorder at whatever the current proposal is, and end the session.
|
||||
let commit: () -> Void
|
||||
}
|
||||
|
||||
// MARK: - LaneView
|
||||
|
||||
/// One lane: a title bar and a vertically scrolling masonry of cards (03-board-ui.md § Lane).
|
||||
@@ -29,9 +11,11 @@ struct LaneHeaderDrag {
|
||||
/// (`ItemSymbol`) — then the title or its quiet "Untitled" placeholder, a quiet secondary
|
||||
/// card-count badge, and a trailing quiet new-card button. **The whole bar is the drag surface**:
|
||||
/// a click selects the lane — toggling off on a repeat, exactly as empty space does
|
||||
/// (04-interactions.md § Selection, settled) — and movement past a small threshold begins a reorder
|
||||
/// (`LaneReorderSession`). The one thing carved out of the drag region is the button, which sits in
|
||||
/// an overlay outside the gesture so a click on it can never be read as the beginning of a drag.
|
||||
/// (04-interactions.md § Selection, settled) — and movement begins a **system drag session**
|
||||
/// carrying the lane (`DragSession`, DRAG-REORDER.md). The click-versus-drag split is the system's
|
||||
/// own now: `.onTapGesture` and `.onDrag` coexist, so a hesitant click can never start a drag and a
|
||||
/// drag can never also select. The one thing carved out of the drag region is the new-card button,
|
||||
/// which sits in an overlay outside it.
|
||||
///
|
||||
/// ### The lane's one context menu
|
||||
///
|
||||
@@ -60,10 +44,14 @@ struct LaneView: View {
|
||||
/// override it (see `BoardView.laneSlot`).
|
||||
let columns: Int
|
||||
|
||||
/// The strip's reorder session, so the header knows whether *it* is the lane in flight.
|
||||
let reorder: LaneReorderSession
|
||||
/// This lane's resting slot width — the replica's width, so the image under the cursor is the
|
||||
/// lane at its real on-screen size (03-board-ui.md § Motion: "a faithful, full-size replica").
|
||||
let slotWidth: CGFloat
|
||||
|
||||
let headerDrag: LaneHeaderDrag
|
||||
/// The board window's drop machinery: the app-wide session, the geometry registry this lane
|
||||
/// registers its card grid into, and the shared retarget every hover and every autoscroll step
|
||||
/// goes through (`BoardDropContext`).
|
||||
let drops: BoardDropContext
|
||||
|
||||
/// The strip's rubber band: the lane's empty space is one of its three surfaces, and every card
|
||||
/// face registers its frame into the same registry (`MarqueeControl`).
|
||||
@@ -88,6 +76,14 @@ struct LaneView: View {
|
||||
/// C7 · full-column top edge (03-board-ui.md § Styling ▸ Capabilities).
|
||||
private let bandHeight: CGFloat = 5
|
||||
|
||||
/// The lane's drawn height, for the replica. Measured rather than derived, because a lane is as
|
||||
/// tall as the strip gives it.
|
||||
@State private var measuredHeight: CGFloat = 0
|
||||
|
||||
/// This lane's edge-autoscroll driver — one per lane, ticking only while a card session is in
|
||||
/// flight (`DragAutoScroller`, DRAG-REORDER.md § Edge autoscroll).
|
||||
@State private var autoScroller = DragAutoScroller()
|
||||
|
||||
var body: some View {
|
||||
// `spacing: 0` and the padding moved inside: the accent band is **full-width** along the
|
||||
// lane's top edge, so it must sit outside the content inset rather than in it.
|
||||
@@ -101,6 +97,14 @@ struct LaneView: View {
|
||||
}
|
||||
.background(selectionBackground)
|
||||
.overlay(selectionStroke)
|
||||
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { measuredHeight = $0 }
|
||||
// **This lane's drop target**, on the whole body. It accepts *both* board types and routes
|
||||
// internally — card sessions against this lane's masonry zones, lane sessions forwarded to
|
||||
// the strip's logic — because single-target dispatch has no fall-through (DRAG-REORDER.md).
|
||||
//
|
||||
// m5-finder-drops: external Finder file sessions join this same target and this same
|
||||
// routing; the type list grows by `.fileURL` and the delegate by one branch.
|
||||
.onDrop(of: boardDragTypes, delegate: LaneDropDelegate(context: drops, laneID: lane.id))
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
@@ -110,7 +114,20 @@ struct LaneView: View {
|
||||
// The bar is the drag surface, so it must be hit-testable across its whole width —
|
||||
// including the empty stretch between the badge and the button.
|
||||
.contentShape(Rectangle())
|
||||
.gesture(headerGesture)
|
||||
// **The header toggles like empty space** (04-interactions.md § Selection, settled): "a
|
||||
// click on the already-selected lane's header unselects, one lane-click behavior
|
||||
// everywhere, so a full lane keeps a pointer path out of selection". Hence the same
|
||||
// `togglesOnRepeat` the empty space passes — the two surfaces differ only in where they
|
||||
// are. `.onTapGesture` beside `.onDrag` is the click-versus-drag split: the system holds
|
||||
// the drag off until the pointer actually moves, so a click is never a drag.
|
||||
.onTapGesture {
|
||||
store.click(
|
||||
SelectionTarget(id: lane.id, kind: .lane, side: .live),
|
||||
modifier: .current,
|
||||
togglesOnRepeat: true
|
||||
)
|
||||
}
|
||||
.onDrag(startLaneDrag, preview: { dragReplica })
|
||||
.overlay(alignment: .trailing) { newCardButton }
|
||||
.contextMenu { laneMenu }
|
||||
// The lane's half of the Style… popover. Anchored on the header because that is the
|
||||
@@ -272,40 +289,106 @@ struct LaneView: View {
|
||||
.disabled(store.isReadOnly || store.isEditingInline)
|
||||
}
|
||||
|
||||
/// One gesture recognising both halves of 04-interactions.md ▸ Selection's click-vs-drag split:
|
||||
/// "a plain click on the title bar selects the lane; the drag surface engages only on movement".
|
||||
// MARK: - The lane drag
|
||||
|
||||
/// Begins the lane's system drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
||||
///
|
||||
/// `minimumDistance: 0` so the release is seen even when nothing moved — that release *is* the
|
||||
/// click. `.global` coordinates because the strip's own space shifts as siblings reflow under
|
||||
/// the proposal, and a translation measured against a moving frame is not a pointer delta.
|
||||
private var headerGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 0, coordinateSpace: .global)
|
||||
.onChanged { value in
|
||||
// Selection stays live under the lock; reordering does not (02 § The lock's scope).
|
||||
// The focused-editor rule holds a drag off too: a reorder is a board command.
|
||||
guard !store.isReadOnly, !store.isEditingInline else { return }
|
||||
if !reorder.isDragging(lane.id) {
|
||||
guard abs(value.translation.width) > LaneReorderSession.threshold else { return }
|
||||
reorder.begin(laneID: lane.id, startCentre: headerDrag.startCentre())
|
||||
}
|
||||
reorder.update(translation: value.translation.width)
|
||||
}
|
||||
.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
|
||||
/// **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
|
||||
)
|
||||
}
|
||||
)
|
||||
drops.session.beginLanes(
|
||||
members.map(\.id),
|
||||
folders: payload.folders,
|
||||
// The dragged items' own sizes, frozen at drag start — the one thing that is
|
||||
// (03-board-ui.md § Motion).
|
||||
units: members.map { LaneLayoutMath.displayUnits(of: $0) },
|
||||
source: store
|
||||
)
|
||||
return payload.itemProvider()
|
||||
}
|
||||
|
||||
/// The image travelling under the cursor: **a faithful, full-size replica of the whole lane**,
|
||||
/// not the strip of title bar that was grabbed (03-board-ui.md § Motion), fanned with ghosts and
|
||||
/// a count badge for a multi-drag.
|
||||
///
|
||||
/// A static rendition rather than a live `LaneView`: a drag image is a snapshot, so it carries no
|
||||
/// scrolling, no gestures and no geometry observers, and the card list is capped because anything
|
||||
/// past the lane's height is clipped anyway.
|
||||
private var dragReplica: some View {
|
||||
let count = max(1, draggedLaneCount)
|
||||
return ZStack {
|
||||
if count > 2 { replicaFace.offset(x: 12, y: 12).opacity(0.45) }
|
||||
if count > 1 { replicaFace.offset(x: 6, y: 6).opacity(0.7) }
|
||||
replicaFace
|
||||
}
|
||||
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
|
||||
.padding(12)
|
||||
}
|
||||
|
||||
private var draggedLaneCount: Int {
|
||||
let selection = store.selection
|
||||
guard selection.liveness == .live, selection.ids.contains(lane.id) else { return 1 }
|
||||
return selection.ids.count
|
||||
}
|
||||
|
||||
private var replicaFace: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
accentBand
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
headerContent
|
||||
VStack(alignment: .leading, spacing: cardSpacing) {
|
||||
ForEach(renderedCards.prefix(12)) { card in
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||||
.foregroundStyle(.secondary)
|
||||
.imageScale(.medium)
|
||||
Text(card.title.value ?? "Untitled")
|
||||
.font(.body)
|
||||
.lineLimit(2)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(.background.secondary))
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
.padding(6)
|
||||
}
|
||||
.frame(width: max(slotWidth, 80), height: max(measuredHeight, 120), alignment: .topLeading)
|
||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background))
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
@@ -343,10 +426,16 @@ struct LaneView: View {
|
||||
store: store,
|
||||
card: card,
|
||||
registry: marquee.registry,
|
||||
drops: drops,
|
||||
openCard: openCard
|
||||
)
|
||||
case .placeholder:
|
||||
NewCardStubView(store: store, openCard: openCard)
|
||||
case let .shadow(_, height):
|
||||
// One of the drag's N contiguous shadows, at the dragged card's frozen
|
||||
// height — the run's real footprint, so the drop lands exactly here.
|
||||
DragShadow(cornerRadius: 8)
|
||||
.frame(height: height)
|
||||
}
|
||||
}
|
||||
// "Appear/disappear is scale + fade (cards scale from ~0.8 …)"
|
||||
@@ -363,6 +452,30 @@ struct LaneView: View {
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
// The drag's reflow-to-make-room inside the lane, keyed on **this lane's drop proposal**
|
||||
// and nothing broader (03-board-ui.md § Motion). `MasonryLayout` is a `Layout` over one
|
||||
// `ForEach` precisely so the round-robin reshuffle animates as positional slides rather
|
||||
// than as remove/insert blinks (DRAG-REORDER.md § The card masonry).
|
||||
.animation(Motion.dragReflow(reduced: reduceMotion), value: cardProposal)
|
||||
// Where this lane's card grid is drawn, in the window's global space — the analytic
|
||||
// resting grid the drop model replays `MasonryPlacement` over. Registered rather than
|
||||
// re-derived, so the zones and the drawn grid cannot disagree.
|
||||
.onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { frame in
|
||||
drops.registry.update(
|
||||
LaneDropRegistry.Grid(frame: frame, columns: max(1, columns), spacing: cardSpacing),
|
||||
for: lane.id
|
||||
)
|
||||
}
|
||||
.onDisappear { drops.registry.removeGrid(lane.id) }
|
||||
// The edge-autoscroll anchor, **inside** the scroll view's content so
|
||||
// `enclosingScrollView` resolves (`DragAutoScrollAnchor`). Every scroll step re-resolves
|
||||
// the proposal through the same shared retarget the drop delegate uses, because the
|
||||
// cursor is stationary while the content moves under it.
|
||||
.background {
|
||||
DragAutoScrollAnchor(scroller: autoScroller) {
|
||||
drops.retargetCards(inLane: lane.id)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
// Order matters: the two-tap recogniser must be attached first so a double click is not
|
||||
// consumed as two singles.
|
||||
@@ -387,22 +500,51 @@ struct LaneView: View {
|
||||
// space alike" (03-board-ui.md § Lane, settled).
|
||||
.contextMenu { laneMenu }
|
||||
}
|
||||
// The autoscroll driver, **structurally terminated**: a `.task(id:)` keyed on whether a card
|
||||
// session is in flight at all, so it is cancelled the moment the session ends — and
|
||||
// `DragSession`'s watchdog guarantees that flag clears however the drag finished
|
||||
// (DRAG-REORDER.md § Edge autoscroll). Within a session, a pointer outside this lane's
|
||||
// engagement rect simply scrolls nothing.
|
||||
.task(id: drops.session.isDraggingCards) {
|
||||
guard drops.session.isDraggingCards else { return }
|
||||
await autoScroller.run()
|
||||
}
|
||||
}
|
||||
|
||||
/// What the masonry lays out: the rendered cards, plus the new-card placeholder when this lane
|
||||
/// is the one being created into.
|
||||
/// Where a card drag would land **in this lane**, or `nil` when the proposal is elsewhere — the
|
||||
/// shadow run's position, and the reflow's narrow animation key.
|
||||
private var cardProposal: Int? {
|
||||
drops.session.laneProposal(onBoardRooted: store.rootURL, laneID: lane.id)
|
||||
}
|
||||
|
||||
/// What the masonry lays out: the rendered cards, the drag's N contiguous shadows at the
|
||||
/// proposal, and the new-card placeholder when this lane is the one being created into.
|
||||
///
|
||||
/// The overlay is inserted **at the position the card will actually take** — after its anchor
|
||||
/// The placeholder is inserted **at the position the card will actually take** — after its anchor
|
||||
/// for ⌘N's "immediately after it", at the bottom otherwise — by asking the very function the
|
||||
/// commit uses to compute the rank (`BoardStore.insertionIndex`). One answer, so the pseudo-card
|
||||
/// cannot appear anywhere but where the real card lands.
|
||||
/// cannot appear anywhere but where the real card lands. Both insertions are computed against
|
||||
/// `renderedCards`, and the placeholder's is shifted past a shadow run that opened in front of
|
||||
/// it, so neither displaces the other.
|
||||
private var slots: [LaneSlot] {
|
||||
var result = renderedCards.map(LaneSlot.card)
|
||||
guard let placeholder = store.transient.newCardPlaceholder, placeholder.laneID == lane.id else {
|
||||
return result
|
||||
|
||||
let shadowPosition = cardProposal.map { min(max(0, $0), result.count) }
|
||||
var placeholderPosition: Int?
|
||||
if let placeholder = store.transient.newCardPlaceholder, placeholder.laneID == lane.id {
|
||||
placeholderPosition = BoardStore.insertionIndex(after: placeholder.anchorCardID, among: renderedCards)
|
||||
?? result.count
|
||||
}
|
||||
|
||||
let heights = drops.session.cardHeights
|
||||
if let shadowPosition {
|
||||
let shadows = heights.enumerated().map { LaneSlot.shadow(index: $0.offset, height: $0.element) }
|
||||
result.insert(contentsOf: shadows, at: shadowPosition)
|
||||
}
|
||||
if var position = placeholderPosition {
|
||||
if let shadowPosition, position >= shadowPosition { position += heights.count }
|
||||
result.insert(.placeholder, at: min(position, result.count))
|
||||
}
|
||||
let position = BoardStore.insertionIndex(after: placeholder.anchorCardID, among: renderedCards)
|
||||
result.insert(.placeholder, at: position ?? result.count)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -411,10 +553,17 @@ struct LaneView: View {
|
||||
/// liveness). The lane half of that rule is `BoardView`'s, which never builds a `LaneView` for a
|
||||
/// tombstoned lane at all.
|
||||
///
|
||||
/// **A dragged card renders nowhere either, for as long as the session lasts.** It is lifted out
|
||||
/// of the resting layout at pickup and stays out until release *whatever the effective operation
|
||||
/// is* — a ⌥-copy's originals really do stay, but ⌥ can be pressed and released mid-drag, and a
|
||||
/// layout that re-admitted them on every flip would flap the board under the cursor
|
||||
/// (DRAG-REORDER.md § Resting-layout zones). The copy's originals reappear when the write lands.
|
||||
///
|
||||
/// This is also the collection m5's search filter narrows, which is what keeps the count badge
|
||||
/// honest for free — see `countBadge`.
|
||||
private var renderedCards: [Card] {
|
||||
lane.cards.filter { !$0.isDeleted }
|
||||
let hidden = drops.session.hiddenMembers(onBoardRooted: store.rootURL)
|
||||
return lane.cards.filter { !$0.isDeleted && !hidden.contains($0.id) }
|
||||
}
|
||||
|
||||
// MARK: - Selection
|
||||
@@ -464,6 +613,8 @@ struct LaneView: View {
|
||||
private enum LaneSlot: Identifiable {
|
||||
case card(Card)
|
||||
case placeholder
|
||||
/// One of a drag's N contiguous shadows, at the dragged card's frozen height.
|
||||
case shadow(index: Int, height: CGFloat)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
@@ -471,6 +622,9 @@ private enum LaneSlot: Identifiable {
|
||||
// Constant, because there is only ever one placeholder in one lane at a time and it must
|
||||
// keep its identity — and therefore its keyboard focus — while the user types.
|
||||
case .placeholder: "placeholder"
|
||||
// Constant per position in the run, so the shadows animate as slides when the proposal moves
|
||||
// rather than blinking out and back in.
|
||||
case let .shadow(index, _): "shadow:\(index)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,6 +672,10 @@ private struct CardFaceView: View {
|
||||
/// here and takes it out again when it leaves — see `View.marqueeTarget`.
|
||||
let registry: MarqueeTargetRegistry
|
||||
|
||||
/// The board window's drop machinery: this face registers its measured height into the geometry
|
||||
/// registry (the resting grid's input) and starts the card drag session from `.onDrag`.
|
||||
let drops: BoardDropContext
|
||||
|
||||
let openCard: (ItemID) -> Void
|
||||
|
||||
/// The app-wide quick-style recents — see `LaneView`'s own note.
|
||||
@@ -572,6 +730,17 @@ private struct CardFaceView: View {
|
||||
guard ClickModifier.current == .plain else { return }
|
||||
openCard(card.id)
|
||||
})
|
||||
// **The whole face is the drag surface** (04-interactions.md ▸ Drag and drop). `.onDrag`
|
||||
// beside the tap recognisers above is the click-versus-drag split, the system's own: it holds
|
||||
// the session off until the pointer really moves, so selecting and opening stay instant.
|
||||
.onDrag(startCardDrag, preview: { dragReplica })
|
||||
// The card's height, for the drop model's analytic resting grid. A height is content-driven
|
||||
// and does not animate under the reflow — only positions do, and those are never measured
|
||||
// (`LaneDropRegistry`).
|
||||
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in
|
||||
drops.registry.update(height: height, for: card.id)
|
||||
}
|
||||
.onDisappear { drops.registry.removeHeight(card.id) }
|
||||
.marqueeTarget(card.id, kind: .card, side: .live, in: registry)
|
||||
.contextMenu { cardMenu }
|
||||
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
|
||||
@@ -579,6 +748,104 @@ private struct CardFaceView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The card drag
|
||||
|
||||
/// Begins the card's system drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
||||
///
|
||||
/// **Dragging any member of a multi-selection drags the whole selection**, in **flatten order** —
|
||||
/// "lane `order` first, then card `order`", `SelectionGrammar.liveCards`' single definition of
|
||||
/// it, which is also the order the drop inserts in. A card outside the selection drags alone.
|
||||
///
|
||||
/// Refused under the read-only lock and while an inline editor is focused, like every other
|
||||
/// mutating gesture; a refusal is an item provider carrying nothing, so no session begins.
|
||||
private func startCardDrag() -> NSItemProvider {
|
||||
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
||||
let snapshot = store.snapshot
|
||||
let selection = store.selection
|
||||
let ids: Set<ItemID> = selection.liveness == .live
|
||||
&& selection.ids.contains(card.id)
|
||||
&& selection.ids.count > 1
|
||||
? selection.ids
|
||||
: [card.id]
|
||||
|
||||
// Flatten order, and the lane each member currently lives in — the folder path's middle
|
||||
// component.
|
||||
var lanesByCard: [ItemID: ItemID] = [:]
|
||||
var titles: [ItemID: String?] = [:]
|
||||
for lane in snapshot.lanes where !lane.isDeleted {
|
||||
for member in lane.cards where !member.isDeleted && ids.contains(member.id) {
|
||||
lanesByCard[member.id] = lane.id
|
||||
titles[member.id] = member.title.value
|
||||
}
|
||||
}
|
||||
let ordered = SelectionGrammar.liveCards(in: snapshot).filter { ids.contains($0) }
|
||||
guard !ordered.isEmpty else { return NSItemProvider() }
|
||||
|
||||
let root = store.rootURL
|
||||
let payload = DragPayload(
|
||||
boardRoot: root,
|
||||
kind: .cards,
|
||||
side: .live,
|
||||
items: ordered.compactMap { id in
|
||||
guard let laneID = lanesByCard[id] else { return nil }
|
||||
return DragPayload.Item(
|
||||
id: id.rawValue,
|
||||
folder: root
|
||||
.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(id.rawValue, isDirectory: true)
|
||||
.path,
|
||||
title: titles[id] ?? nil
|
||||
)
|
||||
}
|
||||
)
|
||||
drops.session.beginCards(
|
||||
ordered,
|
||||
folders: payload.folders,
|
||||
// The dragged items' sizes, frozen at drag start — the pickup transition scales the
|
||||
// replica, and its lingering "last measured frame" would mis-size the shadow and the
|
||||
// span-cap (03-board-ui.md § Motion).
|
||||
heights: ordered.map { drops.registry.heights[$0] ?? LaneDropRegistry.nominalCardHeight },
|
||||
side: .live,
|
||||
source: store
|
||||
)
|
||||
return payload.itemProvider()
|
||||
}
|
||||
|
||||
/// The image travelling under the cursor: this card's face at its real size, fanned with ghosts
|
||||
/// and a count badge when the whole multi-selection rides along (03-board-ui.md § Motion).
|
||||
private var dragReplica: some View {
|
||||
let count = store.selection.liveness == .live && store.selection.ids.contains(card.id)
|
||||
? max(1, store.selection.ids.count)
|
||||
: 1
|
||||
return ZStack {
|
||||
if count > 2 { replicaFace.offset(x: 10, y: 10).opacity(0.45) }
|
||||
if count > 1 { replicaFace.offset(x: 5, y: 5).opacity(0.7) }
|
||||
replicaFace
|
||||
}
|
||||
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
|
||||
.padding(12)
|
||||
}
|
||||
|
||||
/// A static rendition of the face — a drag image is a snapshot, so it carries no gestures, no
|
||||
/// editor and no geometry observers.
|
||||
private var replicaFace: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||||
.foregroundStyle(iconTint)
|
||||
.imageScale(.medium)
|
||||
Text(card.title.value ?? "Untitled")
|
||||
.font(.body)
|
||||
.lineLimit(4)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
attachmentsIndicator
|
||||
}
|
||||
.padding(10)
|
||||
.padding(.leading, stripeWidth)
|
||||
.frame(width: 220, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
|
||||
.overlay(alignment: .leading) { accentStripe }
|
||||
}
|
||||
|
||||
// MARK: - Context menu
|
||||
|
||||
/// Open, Rename, Style…, the quick-style recents row, Delete (11-command-nexus.md ▸ Context
|
||||
|
||||
@@ -3,7 +3,7 @@ import Observation
|
||||
|
||||
// MARK: - MarqueeSession
|
||||
|
||||
/// Window-local state for an in-flight rubber band — `LaneReorderSession`'s sibling, and as small
|
||||
/// Window-local state for an in-flight rubber band — `LaneResizeSession`'s sibling, and as small
|
||||
/// for its reason: it holds only what the *pointer* contributes, because everything the selection
|
||||
/// needs beyond that is read fresh at gesture time (`MarqueeTargetRegistry`, `MarqueeMath`).
|
||||
///
|
||||
|
||||
@@ -29,7 +29,7 @@ extension ClickModifier {
|
||||
/// What a board window lends its empty surfaces so each can be a rubber band: the one session, the
|
||||
/// one target registry, and the store the band selects into.
|
||||
///
|
||||
/// `LaneHeaderDrag`'s sibling in role — the strip owning state that a leaf gesture needs — but a
|
||||
/// `BoardDropContext`'s sibling in role — the strip owning state that a leaf gesture needs — but a
|
||||
/// value rather than a pair of closures, because all three surfaces (lane empty space, the board
|
||||
/// backdrop, the trash column) want the *same* gesture rather than three variations threaded with
|
||||
/// different geometry. Only the side differs, and that is the parameter.
|
||||
|
||||
+107
-116
@@ -2,70 +2,6 @@ import AppKit
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - The strip's half of a trash row's drag
|
||||
|
||||
/// What the strip lends the trash so a row can be dragged back onto the board (03-board-ui.md §
|
||||
/// Trash ▸ Drag-to-restore).
|
||||
///
|
||||
/// `LaneHeaderDrag`'s sibling, and for its reason: the gesture lives on the row, but the one thing it
|
||||
/// needs — *which lane is under the cursor* — is the strip's geometry, and it must be read at gesture
|
||||
/// time rather than at body-evaluation time.
|
||||
@MainActor
|
||||
struct TrashRowDrag {
|
||||
/// The live lane under an x in strip coordinates, or `nil` when the point is not over one — a
|
||||
/// gap, the outer margin, or the trash itself (`LaneLayoutMath.laneIndex`).
|
||||
let laneUnder: (CGFloat) -> ItemID?
|
||||
}
|
||||
|
||||
// MARK: - TrashDragSession
|
||||
|
||||
/// Window-local state for an in-flight drag out of the trash — `LaneReorderSession`'s sibling, and
|
||||
/// deliberately as small.
|
||||
///
|
||||
/// It holds only what the pointer contributes: which card, and which lane is currently under it.
|
||||
/// Everything else — the strip's geometry, the lanes themselves — is read fresh at render time, so a
|
||||
/// foreign reload mid-drag cannot leave this holding a stale board (04-interactions.md ▸ Drag and
|
||||
/// drop's re-grounding rule).
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TrashDragSession {
|
||||
|
||||
/// The card being dragged out of the trash; `nil` when idle.
|
||||
private(set) var cardID: ItemID?
|
||||
|
||||
/// The live lane the pointer is over, or `nil` when it is over nothing droppable. Observed: the
|
||||
/// lanes read it to draw the drop highlight, which is this milestone's whole visual feedback.
|
||||
private(set) var targetLaneID: ItemID?
|
||||
|
||||
/// How far the pointer must travel before a click on a row becomes a drag — the same threshold
|
||||
/// the lane header uses, so the two gestures feel alike.
|
||||
static let threshold: CGFloat = 4
|
||||
|
||||
var isActive: Bool { cardID != nil }
|
||||
|
||||
func isDragging(_ id: ItemID) -> Bool { cardID == id }
|
||||
|
||||
func isTarget(_ id: ItemID) -> Bool { targetLaneID == id }
|
||||
|
||||
func begin(cardID: ItemID) {
|
||||
self.cardID = cardID
|
||||
targetLaneID = nil
|
||||
}
|
||||
|
||||
func update(targetLaneID: ItemID?) {
|
||||
guard isActive else { return }
|
||||
self.targetLaneID = targetLaneID
|
||||
}
|
||||
|
||||
/// Ends the drag, handing the caller nothing — the *commit* needs the current snapshot, which
|
||||
/// the view has and this session deliberately does not. Idempotent, because a gesture can end
|
||||
/// after the card it was carrying has already vanished.
|
||||
func end() {
|
||||
cardID = nil
|
||||
targetLaneID = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TrashLaneView
|
||||
|
||||
/// The trash quasi-lane: the trailing, visually distinct column tombstoned items live in
|
||||
@@ -81,8 +17,11 @@ final class TrashDragSession {
|
||||
/// the edge drag never reaches it (`LaneLayoutMath.totalUnits(of:trashUnits:)` supplies the unit,
|
||||
/// so there is no `Lane` value for any of those to act on);
|
||||
/// - it is **not draggable and not reorderable** — the header carries no gesture, and it is absent
|
||||
/// from the reorder proposal's `unitCounts` by construction, since `BoardView` builds that from
|
||||
/// the snapshot's live lanes;
|
||||
/// from the drop proposal's slot list by construction, since `BoardView` builds that from the
|
||||
/// snapshot's live lanes;
|
||||
/// - it is **never a drop target** — "no move or paste ever targets the trash" (04-interactions.md ▸
|
||||
/// The trash), so nothing here declares an `onDrop` at all and a session over the column falls
|
||||
/// through to the strip's own target, where a cursor over no lane simply holds the proposal;
|
||||
/// - it has **no new-card button**: nothing is created in the trash.
|
||||
///
|
||||
/// ### No editing in the trash
|
||||
@@ -110,12 +49,9 @@ struct TrashLaneView: View {
|
||||
/// does.
|
||||
let confirmations: TrashConfirmations
|
||||
|
||||
/// The strip's drop resolution — see `TrashRowDrag`.
|
||||
let drag: TrashRowDrag
|
||||
|
||||
/// The window's one drag-out session, owned by `BoardView` for the same lifetime the reorder
|
||||
/// session has.
|
||||
let dragSession: TrashDragSession
|
||||
/// The board window's drop machinery — a row's drag is an ordinary card session on the
|
||||
/// **trashed** side (`DragSession`, 04-interactions.md ▸ The trash).
|
||||
let drops: BoardDropContext
|
||||
|
||||
/// The strip's rubber band. The column's empty space is its third surface, on the **trashed**
|
||||
/// side — "a rubber-band stays on the side of the boundary it started on" (04-interactions.md ▸
|
||||
@@ -234,8 +170,7 @@ struct TrashLaneView: View {
|
||||
store: store,
|
||||
entry: entry,
|
||||
confirmations: confirmations,
|
||||
drag: drag,
|
||||
dragSession: dragSession,
|
||||
drops: drops,
|
||||
registry: marquee.registry
|
||||
)
|
||||
// A row is a tombstoned item, so it arrives and leaves in the card's dialect —
|
||||
@@ -295,8 +230,7 @@ private struct TrashEntryRow: View {
|
||||
let store: BoardStore
|
||||
let entry: TrashEntry
|
||||
let confirmations: TrashConfirmations
|
||||
let drag: TrashRowDrag
|
||||
let dragSession: TrashDragSession
|
||||
let drops: BoardDropContext
|
||||
|
||||
/// Where the rubber band looks up what it is sweeping — the card face's rule, on the trashed
|
||||
/// side (`View.marqueeTarget`).
|
||||
@@ -304,7 +238,38 @@ private struct TrashEntryRow: View {
|
||||
|
||||
private let cornerRadius: CGFloat = 6
|
||||
|
||||
/// **Lane entries are not draggable** (03-board-ui.md § Trash: "a lane entry is not draggable —
|
||||
/// its entry is a compact row, not the lane; its move-out is Put Back"), so the drag half is
|
||||
/// simply *absent* for them rather than refused — no session, no image, no snap-back. A click
|
||||
/// still selects either way.
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
if entry.isLaneEntry {
|
||||
plate
|
||||
} else {
|
||||
plate.onDrag(startRowDrag, preview: { dragReplica })
|
||||
}
|
||||
}
|
||||
|
||||
private var plate: some View {
|
||||
rowFace
|
||||
// The row being dragged out dims in place — the source stays visible in the trash,
|
||||
// because a restore is not a removal until the write lands.
|
||||
.opacity(drops.session.isDragging(entry.id) ? 0.45 : 1)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { select() }
|
||||
.marqueeTarget(entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed, in: registry)
|
||||
.contextMenu { menu }
|
||||
}
|
||||
|
||||
/// The plate's *appearance*, with none of its behaviour — no gesture, no context menu, and
|
||||
/// crucially no marquee registration.
|
||||
///
|
||||
/// The split exists for the drag replica, which renders this and nothing else: a drag image is a
|
||||
/// snapshot, and one built out of the live plate would re-register this row's frame from inside
|
||||
/// the preview's own geometry and then *deregister* it when the image went away, quietly
|
||||
/// stealing the row from the rubber band and the arrow keys.
|
||||
private var rowFace: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Image(systemName: ItemSymbol.name(entry.icon, fallback: symbolFallback))
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -331,12 +296,6 @@ private struct TrashEntryRow: View {
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5)
|
||||
)
|
||||
// The row being dragged out dims further, so the gesture reads even without a replica.
|
||||
.opacity(dragSession.isDragging(entry.id) ? 0.45 : 1)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(rowGesture)
|
||||
.marqueeTarget(entry.id, kind: entry.isLaneEntry ? .lane : .card, side: .trashed, in: registry)
|
||||
.contextMenu { menu }
|
||||
}
|
||||
|
||||
private var symbolFallback: String {
|
||||
@@ -367,46 +326,78 @@ private struct TrashEntryRow: View {
|
||||
|
||||
// MARK: - Drag out
|
||||
|
||||
/// One gesture recognising the same click-versus-drag split the lane header uses: a plain click
|
||||
/// selects, and only movement past the threshold begins a drag out of the trash.
|
||||
/// Begins the row's drag out of the trash — an ordinary **card session on the trashed side**,
|
||||
/// which is the whole of what makes it a restore rather than a move (04-interactions.md ▸ The
|
||||
/// trash; `DragLocality.operation`).
|
||||
///
|
||||
/// **Lane entries are not draggable** (03-board-ui.md § Trash: "a lane entry is not draggable —
|
||||
/// its entry is a compact row, not the lane; its move-out is Put Back"), so the drag half is
|
||||
/// simply absent for them and the release still selects.
|
||||
/// Where it lands is the ordinary drop model's answer: `DropSlotMath.cardSlot` over the
|
||||
/// destination lane's masonry, so "restores it at the drop position" is the same arithmetic every
|
||||
/// other card drop uses. What a release *means* differs by locality and modifier, and that lives
|
||||
/// in one place (`BoardDropContext.commitDrop`): within the board a restore, ⌥ a live copy-out,
|
||||
/// across boards a live copy with ⌘ forcing the true restore-move.
|
||||
///
|
||||
/// The coordinate space is the strip's, because what the release needs is a *position* over the
|
||||
/// board, not a translation.
|
||||
private var rowGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 0, coordinateSpace: .named(BoardView.stripSpace))
|
||||
.onChanged { value in
|
||||
guard isDraggable, !store.isReadOnly, !store.isEditingInline else { return }
|
||||
if !dragSession.isDragging(entry.id) {
|
||||
let travelled = max(abs(value.translation.width), abs(value.translation.height))
|
||||
guard travelled > TrashDragSession.threshold else { return }
|
||||
dragSession.begin(cardID: entry.id)
|
||||
/// **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)
|
||||
}
|
||||
dragSession.update(targetLaneID: drag.laneUnder(value.location.x))
|
||||
}
|
||||
.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
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// The drag session's **value** halves — the pasteboard payload, the locality model, and the
|
||||
/// committed overlay's hand-off condition (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
||||
///
|
||||
/// The session object itself, the drop delegates and the gestures are not unit-testable — they are
|
||||
/// deliberately thin over these three, plus `DropSlotMath`'s arithmetic, which is why the split falls
|
||||
/// where it does.
|
||||
|
||||
// MARK: - The payload
|
||||
|
||||
@Suite("DragPayload")
|
||||
struct DragPayloadTests {
|
||||
|
||||
private static func payload(kind: DragKind = .cards, side: Liveness = .live) -> DragPayload {
|
||||
DragPayload(
|
||||
boardRoot: URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true),
|
||||
kind: kind,
|
||||
side: side,
|
||||
items: [
|
||||
DragPayload.Item(id: "aaa", folder: "/Boards/Work.kanban/lane/aaa", title: "First"),
|
||||
DragPayload.Item(id: "bbb", folder: "/Boards/Work.kanban/lane/bbb", title: nil)
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@Test("A payload round-trips through its JSON representation unchanged")
|
||||
func roundTrip() throws {
|
||||
for kind in [DragKind.cards, .lanes] {
|
||||
for side in [Liveness.live, .trashed] {
|
||||
let original = Self.payload(kind: kind, side: side)
|
||||
let data = try #require(original.encoded())
|
||||
#expect(DragPayload(data: data) == original)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Garbage decodes to nothing rather than to an empty drag")
|
||||
func garbageDecodesToNil() {
|
||||
#expect(DragPayload(data: Data("not json".utf8)) == nil)
|
||||
#expect(DragPayload(data: Data()) == nil)
|
||||
}
|
||||
|
||||
@Test("The ids, folders and root are read back off the strings, in flatten order")
|
||||
func derivedValues() {
|
||||
let payload = Self.payload()
|
||||
#expect(payload.ids == [ItemID(rawValue: "aaa"), ItemID(rawValue: "bbb")])
|
||||
#expect(payload.folders.map(\.path) == [
|
||||
"/Boards/Work.kanban/lane/aaa",
|
||||
"/Boards/Work.kanban/lane/bbb"
|
||||
])
|
||||
#expect(payload.rootURL.path == "/Boards/Work.kanban")
|
||||
}
|
||||
|
||||
@Test("The plain-text representation is the dragged titles, one per line")
|
||||
func plainText() {
|
||||
// The stray-drop-into-a-text-editor fallback. An untitled item renders as the board renders
|
||||
// it — "Untitled" is a rendering, never a value (03-board-ui.md § Card face).
|
||||
#expect(Self.payload().plainText == "First\nUntitled")
|
||||
}
|
||||
|
||||
@Test("The side survives the round trip, because it is what makes a trash drag a trash drag")
|
||||
func sideSurvives() throws {
|
||||
let data = try #require(Self.payload(side: .trashed).encoded())
|
||||
#expect(DragPayload(data: data)?.side.liveness == .trashed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Locality
|
||||
|
||||
@Suite("DragLocality")
|
||||
struct DragLocalityTests {
|
||||
|
||||
private static let here = URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true)
|
||||
private static let there = URL(fileURLWithPath: "/Boards/Home.kanban", isDirectory: true)
|
||||
|
||||
private static let none: NSEvent.ModifierFlags = []
|
||||
private static let option: NSEvent.ModifierFlags = [.option]
|
||||
private static let command: NSEvent.ModifierFlags = [.command]
|
||||
|
||||
@Test("Roots compare by their standardized path, so the same board is the same board")
|
||||
func rootComparison() {
|
||||
#expect(DragLocality.isSameBoard(here, here))
|
||||
#expect(DragLocality.isSameBoard(here, URL(fileURLWithPath: "/Boards/./Work.kanban/")))
|
||||
#expect(DragLocality.isSameBoard(here, URL(fileURLWithPath: "/Boards/Other/../Work.kanban")))
|
||||
#expect(!DragLocality.isSameBoard(here, there))
|
||||
}
|
||||
|
||||
/// The Finder volume model: within a board a drag rearranges, between boards it transfers.
|
||||
@Test("Locality picks the default — within is a move, across is a copy")
|
||||
func theDefault() {
|
||||
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: true, modifiers: none) == .move)
|
||||
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: false, modifiers: none) == .copy)
|
||||
#expect(DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: false, modifiers: none) == .copy)
|
||||
}
|
||||
|
||||
@Test("⌥ forces copy and ⌘ forces move, each a no-op where it is already the default")
|
||||
func modifiersOverride() {
|
||||
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: true, modifiers: option) == .copy)
|
||||
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: false, modifiers: command) == .move)
|
||||
// The no-ops.
|
||||
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: true, modifiers: command) == .move)
|
||||
#expect(DragLocality.operation(kind: .cards, side: .live, isWithinBoard: false, modifiers: option) == .copy)
|
||||
}
|
||||
|
||||
@Test("⌘ wins over ⌥ when both are held")
|
||||
func commandWinsOverOption() {
|
||||
// Finder's own reduction, and the same precedence `ClickModifier.current` applies to clicks.
|
||||
#expect(DragLocality.operation(
|
||||
kind: .cards, side: .live, isWithinBoard: false, modifiers: [.option, .command]) == .move)
|
||||
}
|
||||
|
||||
/// The first carve-out: "Lane drags never copy *within their board*. ⌥ is simply ignored there:
|
||||
/// the drag stays a clean reorder and the badge never shows copy."
|
||||
@Test("A within-board lane drag ignores ⌥ entirely")
|
||||
func laneDragsNeverCopyWithinTheirBoard() {
|
||||
for modifiers in [none, option, command, [.option, .command] as NSEvent.ModifierFlags] {
|
||||
#expect(
|
||||
DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: true, modifiers: modifiers) == .move,
|
||||
"a within-board lane drag is a reorder whatever is held"
|
||||
)
|
||||
}
|
||||
// Across boards the lane obeys the ordinary grammar again.
|
||||
#expect(DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: false, modifiers: option) == .copy)
|
||||
#expect(DragLocality.operation(kind: .lanes, side: .live, isWithinBoard: false, modifiers: command) == .move)
|
||||
}
|
||||
|
||||
/// The second: a trash row's drag is copy-out grammar (04-interactions.md ▸ The trash). Within its
|
||||
/// own board the default is the restore — a move, no badge; across boards the default is the live
|
||||
/// copy that leaves the tombstone standing. ⌘ forces the true restore-move either way, and ⌥ the
|
||||
/// live copy either way.
|
||||
@Test("A trash row drags as a restore at home and as a copy-out abroad")
|
||||
func trashDragDefaults() {
|
||||
#expect(DragLocality.operation(kind: .cards, side: .trashed, isWithinBoard: true, modifiers: none) == .move)
|
||||
#expect(DragLocality.operation(kind: .cards, side: .trashed, isWithinBoard: false, modifiers: none) == .copy)
|
||||
#expect(DragLocality.operation(
|
||||
kind: .cards, side: .trashed, isWithinBoard: false, modifiers: command) == .move)
|
||||
#expect(DragLocality.operation(kind: .cards, side: .trashed, isWithinBoard: true, modifiers: option) == .copy)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The committed-overlay hold
|
||||
|
||||
@Suite("CommittedHold")
|
||||
struct CommittedHoldTests {
|
||||
|
||||
private static let here = URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true)
|
||||
private static let there = URL(fileURLWithPath: "/Boards/Home.kanban", isDirectory: true)
|
||||
|
||||
private static let hold = CommittedHold(boardRoot: here, generation: 7)
|
||||
|
||||
@Test("The hold stands until the destination board applies a *newer* snapshot")
|
||||
func retiredByTheNextSnapshot() {
|
||||
// The generation at the commit is the one already on screen — it is the pre-drop arrangement,
|
||||
// and retiring on it would drop the overlay before the write has round-tripped.
|
||||
#expect(!Self.hold.isRetired(byRoot: Self.here, generation: 7))
|
||||
#expect(Self.hold.isRetired(byRoot: Self.here, generation: 8))
|
||||
// *Any* snapshot hands off, not just the app-mediated echo: a foreign one that lands first
|
||||
// re-grounds everything anyway.
|
||||
#expect(Self.hold.isRetired(byRoot: Self.here, generation: 99))
|
||||
}
|
||||
|
||||
@Test("A reload on another board says nothing about this one")
|
||||
func otherBoardsDoNotRetireIt() {
|
||||
#expect(!Self.hold.isRetired(byRoot: Self.there, generation: 99))
|
||||
}
|
||||
|
||||
@Test("The board is matched by identity, not by string")
|
||||
func rootMatchingUsesTheLocalityComparison() {
|
||||
#expect(Self.hold.isRetired(byRoot: URL(fileURLWithPath: "/Boards/./Work.kanban/"), generation: 8))
|
||||
}
|
||||
|
||||
@Test("A stale generation never retires it")
|
||||
func staleGenerations() {
|
||||
#expect(!Self.hold.isRetired(byRoot: Self.here, generation: 0))
|
||||
#expect(!Self.hold.isRetired(byRoot: Self.here, generation: 6))
|
||||
}
|
||||
}
|
||||
@@ -425,6 +425,99 @@ struct ReceiveCardsTests {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Within-board lane moves
|
||||
|
||||
/// A three-lane board, so a run of two can land at either end or between the survivors.
|
||||
@MainActor
|
||||
private func makeThreeLaneBoard() throws -> WriterFixture {
|
||||
let fixture = try WriterFixture()
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||
try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done"))
|
||||
return fixture
|
||||
}
|
||||
|
||||
private func laneTitles(_ fixture: WriterFixture) throws -> [String] {
|
||||
try loaded(fixture).lanes.filter { !$0.isDeleted }.compactMap(\.title.value)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Suite("BoardStore ▸ moveLanes")
|
||||
struct MoveLanesTests {
|
||||
|
||||
@Test("A run of lanes lands contiguously at the drop, in board order")
|
||||
func runLandsContiguously() throws {
|
||||
let fixture = try makeThreeLaneBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let untouched = try stat(fixture, Ident.lane2)
|
||||
|
||||
// Todo and Done together, dropped at 0 — "before what is left", which is Doing. The two
|
||||
// arrive adjacent and in board order even though they were not adjacent to begin with.
|
||||
store.moveLanes([lane1, lane3], toIndex: 0)
|
||||
|
||||
#expect(try laneTitles(fixture) == ["Todo", "Done", "Doing"])
|
||||
// Ranks are inserted, never permuted: the lane that did not move was never opened.
|
||||
#expect(try stat(fixture, Ident.lane2) == untouched)
|
||||
#expect(try order(fixture, Ident.lane2) == .valid(2048))
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A run dropped past the survivors appends in order")
|
||||
func runAppends() throws {
|
||||
let fixture = try makeThreeLaneBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.moveLanes([lane1, lane2], toIndex: 1)
|
||||
|
||||
#expect(try laneTitles(fixture) == ["Done", "Todo", "Doing"])
|
||||
}
|
||||
|
||||
@Test("A single lane behaves exactly as the singular command does")
|
||||
func singleLane() throws {
|
||||
let fixture = try makeThreeLaneBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.moveLanes([lane3], toIndex: 0)
|
||||
|
||||
#expect(try laneTitles(fixture) == ["Done", "Todo", "Doing"])
|
||||
}
|
||||
|
||||
@Test("A drag that lands where everything already is writes nothing")
|
||||
func noOpDropWritesNothing() throws {
|
||||
let fixture = try makeThreeLaneBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let before = try [Ident.lane1, Ident.lane2, Ident.lane3].map { try stat(fixture, $0) }
|
||||
|
||||
store.moveLanes([lane1], toIndex: 0)
|
||||
store.moveLanes([lane1, lane2], toIndex: 0)
|
||||
store.moveLanes([], toIndex: 1)
|
||||
store.moveLanes([ItemID(rawValue: Ident.indexless)], toIndex: 0)
|
||||
|
||||
#expect(try [Ident.lane1, Ident.lane2, Ident.lane3].map { try stat(fixture, $0) } == before)
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
}
|
||||
|
||||
@Test("An out-of-range index clamps rather than trapping")
|
||||
func indexClamps() throws {
|
||||
// A store apiece: a write does not touch the snapshot (the one-way flow), so two drops
|
||||
// against one store would both be computed against the pre-drop board.
|
||||
let high = try makeThreeLaneBoard()
|
||||
defer { high.tearDown() }
|
||||
try BoardStore(rootURL: high.root).moveLanes([lane1], toIndex: 99)
|
||||
#expect(try laneTitles(high) == ["Doing", "Done", "Todo"])
|
||||
|
||||
let low = try makeThreeLaneBoard()
|
||||
defer { low.tearDown() }
|
||||
try BoardStore(rootURL: low.root).moveLanes([lane3], toIndex: -5)
|
||||
#expect(try laneTitles(low) == ["Done", "Todo", "Doing"])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cross-board lane arrivals
|
||||
|
||||
@MainActor
|
||||
@@ -579,6 +672,45 @@ struct RestoreByDragPositionTests {
|
||||
#expect(try titles(lane1, in: fixture) == ["First", "Third", "Trashed"])
|
||||
#expect(try order(fixture, "\(Ident.lane1)/\(Ident.card2)") == .valid(4096))
|
||||
}
|
||||
|
||||
@Test("A multi-row restore lands the run contiguously, in drop order, in one bracket")
|
||||
func multiRestore() throws {
|
||||
let fixture = try WriterFixture()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "TrashedA"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card3)", tombstoned(order: "3072", title: "TrashedB"))
|
||||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||
try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "1024", title: "Fourth"))
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.restoreByDrag(cardIDs: [card3, card2], intoLane: lane2, at: 0)
|
||||
|
||||
#expect(try titles(lane2, in: fixture) == ["TrashedB", "TrashedA", "Fourth"],
|
||||
"the payload's order is the landing order")
|
||||
#expect(TrashModel.isEmpty(try loaded(fixture)))
|
||||
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
|
||||
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card3)"))
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A row that is not a trash row is skipped, and an empty list writes nothing")
|
||||
func skipsWhatIsNotARow() throws {
|
||||
let fixture = try makeTrashBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let untouched = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)")
|
||||
|
||||
// `card1` is live — it has no trash row to drag — and `indexless` names nothing.
|
||||
store.restoreByDrag(cardIDs: [card1, ItemID(rawValue: Ident.indexless)], intoLane: lane2, at: 0)
|
||||
store.restoreByDrag(cardIDs: [], intoLane: lane2, at: 0)
|
||||
|
||||
#expect(try titles(lane2, in: fixture) == ["Fourth"])
|
||||
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)") == untouched)
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -184,8 +184,8 @@ struct SpanCappedSlotTests {
|
||||
|
||||
// MARK: - The lane strip
|
||||
|
||||
/// `standard = 100`, `gap = 10`, matching `LaneReorderMathTests`: a 1× slot is 100 wide, a 2× is
|
||||
/// 210 and a 3× is 320, and the strip's outer margin is one gap, so the first slot starts at 10.
|
||||
/// `standard = 100`, `gap = 10`: a 1× slot is 100 wide, a 2× is 210 and a 3× is 320, and the strip's
|
||||
/// outer margin is one gap, so the first slot starts at 10.
|
||||
@Suite("DropSlotMath ▸ the lane strip")
|
||||
struct LaneSlotTests {
|
||||
private let standard: CGFloat = 100
|
||||
@@ -197,14 +197,14 @@ struct LaneSlotTests {
|
||||
#expect(extents == [10...110, 120...440, 450...550])
|
||||
#expect(DropSlotMath.laneExtents(unitCounts: [], standard: standard, gap: gap).isEmpty)
|
||||
|
||||
// The centres agree with `LaneReorderMath.centre`, which reads the same layout — the two
|
||||
// must never drift, since the drag's replica offsets from one and its proposal from the
|
||||
// other.
|
||||
for index in 0..<3 {
|
||||
let centre = LaneReorderMath.centre(ofLaneAt: index, unitCounts: [1, 3, 1],
|
||||
standard: standard, gap: gap)
|
||||
#expect(centre == (extents[index].lowerBound + extents[index].upperBound) / 2)
|
||||
// Each extent is exactly what `BoardView` frames that lane at, and they tile with one gap
|
||||
// between them — the strip always exactly fills (03-board-ui.md § Layout).
|
||||
for (index, units) in [1, 3, 1].enumerated() {
|
||||
#expect(extents[index].upperBound - extents[index].lowerBound
|
||||
== LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap))
|
||||
}
|
||||
#expect(extents[1].lowerBound - extents[0].upperBound == gap)
|
||||
#expect(extents[2].lowerBound - extents[1].upperBound == gap)
|
||||
}
|
||||
|
||||
@Test("A dragged run's span is its slots plus the gaps between them")
|
||||
@@ -250,6 +250,23 @@ struct LaneSlotTests {
|
||||
#expect(DropSlotMath.laneSlot(cursorX: 200, restingUnits: [], draggedUnits: [1],
|
||||
standard: standard, gap: gap, current: nil) == 0)
|
||||
}
|
||||
|
||||
/// **The trash is never a landing spot** (04-interactions.md ▸ The trash: "no move or paste ever
|
||||
/// targets the trash"). The quasi-lane consumes one unit while shown, and it is excluded from the
|
||||
/// slot list by construction — so the terminal slot's uncapped reach past the last *real* lane
|
||||
/// lands in front of the trash column, never in it or past it.
|
||||
@Test("The end slot stops before the shown trash column, however far the cursor goes")
|
||||
func theEndSlotClampsInFrontOfTheTrash() {
|
||||
// Two 1× lanes plus a shown trash: the strip lays out three units, so the trash occupies
|
||||
// [230, 330]. `restingUnits` names the lanes only.
|
||||
let restingUnits = [1, 1]
|
||||
for x: CGFloat in [240, 300, 330, 900, 5000] {
|
||||
let slot = DropSlotMath.laneSlot(cursorX: x, restingUnits: restingUnits, draggedUnits: [1],
|
||||
standard: standard, gap: gap, current: 0)
|
||||
#expect(slot == restingUnits.count,
|
||||
"a cursor over the trash column at x = \(x) appends after the last real lane")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The masonry's resting grid
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
import CoreGraphics
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// `LaneReorderMath` — the lane drag's proposal, as arithmetic.
|
||||
///
|
||||
/// The board these numbers describe: `standard = 100`, `gap = 10`, so a 1× slot is 100pt wide, a 2×
|
||||
/// slot is 210 (two standards plus the interior gap it swallows) and a 3× is 320. The strip's outer
|
||||
/// margin is one gap, so the first slot starts at x = 10.
|
||||
|
||||
private let standard: CGFloat = 100
|
||||
private let gap: CGFloat = 10
|
||||
|
||||
private func proposal(_ units: [Int], dragging index: Int, centre: CGFloat) -> Int {
|
||||
LaneReorderMath.proposedIndex(
|
||||
unitCounts: units,
|
||||
draggedIndex: index,
|
||||
dragCentreX: centre,
|
||||
standard: standard,
|
||||
gap: gap
|
||||
)
|
||||
}
|
||||
|
||||
@Suite("LaneReorderMath")
|
||||
struct LaneReorderMathTests {
|
||||
|
||||
// MARK: Resting geometry
|
||||
|
||||
@Test("A lane's resting centre is its slot's midpoint, gaps and wide lanes counted")
|
||||
func restingCentres() {
|
||||
// Four 1× lanes: slots at [10, 110), [120, 220), [230, 330), [340, 440).
|
||||
let uniform = [1, 1, 1, 1]
|
||||
#expect(LaneReorderMath.centre(ofLaneAt: 0, unitCounts: uniform, standard: standard, gap: gap) == 60)
|
||||
#expect(LaneReorderMath.centre(ofLaneAt: 1, unitCounts: uniform, standard: standard, gap: gap) == 170)
|
||||
#expect(LaneReorderMath.centre(ofLaneAt: 3, unitCounts: uniform, standard: standard, gap: gap) == 390)
|
||||
|
||||
// A 3× lane in the middle: slots at [10, 110), [120, 440), [450, 550).
|
||||
let mixed = [1, 3, 1]
|
||||
#expect(LaneReorderMath.centre(ofLaneAt: 1, unitCounts: mixed, standard: standard, gap: gap) == 280)
|
||||
#expect(LaneReorderMath.centre(ofLaneAt: 2, unitCounts: mixed, standard: standard, gap: gap) == 500)
|
||||
|
||||
// An index past the end yields the position the next slot would start at, rather than
|
||||
// trapping: the drag's lane can vanish between a render and a gesture callback.
|
||||
#expect(LaneReorderMath.centre(ofLaneAt: 9, unitCounts: mixed, standard: standard, gap: gap) == 560)
|
||||
}
|
||||
|
||||
// MARK: The proposal
|
||||
|
||||
@Test("A lane that has not moved proposes its own index")
|
||||
func restingDragProposesNoChange() {
|
||||
// Dragging lane 1 of four: with it removed the remaining centres are 60, 170, 280. Its own
|
||||
// resting centre is 170, which has passed exactly one of them.
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 1, centre: 170) == 1)
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 60) == 0)
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 3, centre: 390) == 3)
|
||||
}
|
||||
|
||||
@Test("The proposal steps once the cursor passes a remaining lane's centre, and not before")
|
||||
func theThresholdIsTheNeighboursCentre() {
|
||||
// Dragging lane 0 out of four. Remaining slots are the other three, laid out from x = 10:
|
||||
// centres 60, 170, 280.
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 59) == 0)
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 60) == 0, "the boundary itself does not step — strictly past")
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 61) == 1)
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 171) == 2)
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 0, centre: 281) == 3)
|
||||
}
|
||||
|
||||
@Test("A far drag in either direction clamps to the ends")
|
||||
func farDragsClampToTheEnds() {
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 2, centre: -5000) == 0)
|
||||
#expect(proposal([1, 1, 1, 1], dragging: 2, centre: 5000) == 3, "the last index with the lane itself removed")
|
||||
#expect(proposal([1, 1, 1], dragging: 0, centre: 5000) == 2)
|
||||
}
|
||||
|
||||
@Test("Width-aware: a wide neighbour has to be crossed, not merely touched")
|
||||
func wideNeighboursDemandRealTravel() {
|
||||
// Lanes [1, 3, 1] with the 1× at index 0 dragged. The remaining pair is the 3× then the 1×:
|
||||
// slots [10, 330) and [340, 440), centres 170 and 390.
|
||||
//
|
||||
// "No reflow until the cursor reaches where the dragged lane would actually land": at 200 the
|
||||
// cursor is well inside the wide lane but has passed its centre, so the step is honest; at
|
||||
// 150 it has not, and proposing a swap there would reorder the board under a cursor still
|
||||
// sitting over the lane it started left of.
|
||||
#expect(proposal([1, 3, 1], dragging: 0, centre: 150) == 0)
|
||||
#expect(proposal([1, 3, 1], dragging: 0, centre: 200) == 1)
|
||||
#expect(proposal([1, 3, 1], dragging: 0, centre: 400) == 2)
|
||||
}
|
||||
|
||||
@Test("The proposal is monotone in the cursor — it never oscillates")
|
||||
func theProposalIsMonotone() {
|
||||
// One threshold per remaining slot, crossed once, is what makes the shadow stable rather
|
||||
// than jittery (04-interactions.md ▸ Drag and drop). Sweeping the whole strip must therefore
|
||||
// produce a non-decreasing sequence.
|
||||
let units = [2, 1, 3, 1, 2]
|
||||
var last = 0
|
||||
for x in stride(from: CGFloat(-200), through: 1200, by: 1) {
|
||||
let next = proposal(units, dragging: 2, centre: x)
|
||||
#expect(next >= last, "the proposal went backwards as the cursor moved right, at x = \(x)")
|
||||
last = next
|
||||
}
|
||||
#expect(last == units.count - 1)
|
||||
}
|
||||
|
||||
@Test("A single-lane board proposes the only index there is")
|
||||
func singleLaneBoard() {
|
||||
#expect(proposal([1], dragging: 0, centre: -900) == 0)
|
||||
#expect(proposal([1], dragging: 0, centre: 900) == 0)
|
||||
}
|
||||
|
||||
@Test("An out-of-range dragged index yields zero rather than trapping")
|
||||
func vanishedLaneDoesNotTrap() {
|
||||
// The lane vanished under the drag; the caller's release-with-no-valid-proposal rule cancels
|
||||
// anyway, so the only contract here is totality.
|
||||
#expect(proposal([1, 1], dragging: 7, centre: 100) == 0)
|
||||
#expect(proposal([], dragging: 0, centre: 100) == 0)
|
||||
}
|
||||
|
||||
// MARK: Applying a proposal
|
||||
|
||||
@Test("Reordering applies the proposal's own index convention")
|
||||
func reorderedAppliesTheConvention() {
|
||||
let lanes = ["a", "b", "c", "d"]
|
||||
|
||||
// `to` counts positions with the item already removed, which is what `proposedIndex`
|
||||
// returns — so `to == from` must be the identity.
|
||||
#expect(LaneReorderMath.reordered(lanes, from: 1, to: 1) == lanes)
|
||||
#expect(LaneReorderMath.reordered(lanes, from: 0, to: 0) == lanes)
|
||||
|
||||
#expect(LaneReorderMath.reordered(lanes, from: 0, to: 1) == ["b", "a", "c", "d"])
|
||||
#expect(LaneReorderMath.reordered(lanes, from: 0, to: 3) == ["b", "c", "d", "a"])
|
||||
#expect(LaneReorderMath.reordered(lanes, from: 3, to: 0) == ["d", "a", "b", "c"])
|
||||
#expect(LaneReorderMath.reordered(lanes, from: 2, to: 1) == ["a", "c", "b", "d"])
|
||||
}
|
||||
|
||||
@Test("Reordering is total: out-of-range indices clamp or pass through")
|
||||
func reorderedIsTotal() {
|
||||
let lanes = ["a", "b", "c"]
|
||||
#expect(LaneReorderMath.reordered(lanes, from: 9, to: 0) == lanes)
|
||||
#expect(LaneReorderMath.reordered(lanes, from: 0, to: 99) == ["b", "c", "a"])
|
||||
#expect(LaneReorderMath.reordered(lanes, from: 2, to: -5) == ["c", "a", "b"])
|
||||
}
|
||||
|
||||
@Test("A dragged lane parked over each slot in turn lands exactly there")
|
||||
func aRoundTripThroughEverySlot() {
|
||||
// The end-to-end claim the two halves compose into: park the dragged lane on top of a
|
||||
// sibling's resting centre and the proposal, applied, puts it in that sibling's place.
|
||||
let units = [1, 2, 1, 3]
|
||||
let lanes = ["a", "b", "c", "d"]
|
||||
let from = 0
|
||||
var remaining = units
|
||||
remaining.remove(at: from)
|
||||
|
||||
for slot in remaining.indices {
|
||||
let centre = LaneReorderMath.centre(ofLaneAt: slot, unitCounts: remaining, standard: standard, gap: gap)
|
||||
// A hair past the centre is what "passed it" means; sitting exactly on it holds.
|
||||
let landed = proposal(units, dragging: from, centre: centre + 1)
|
||||
#expect(landed == slot + 1)
|
||||
#expect(LaneReorderMath.reordered(lanes, from: from, to: landed).firstIndex(of: "a") == slot + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,12 @@ Lanework is in early development. This list tracks what has actually shipped and
|
||||
- **Storage contract, write side** — BoardWriter turns every mutation into an atomic temp-file+rename over exactly the files it touches: creates mint lowercase-UUIDv4 identities and `.kanban` packages; moves keep the UUID (with per-folder collision repair at the cross-board import boundary); copies mint fresh identities at every level; deletes are in-place tombstones with position-perfect restore and physical purge; attachment imports never overwrite and never refuse (Finder-style renames). Every app write stamps `modified`, clears `modified-by`, and preserves everything it didn't change byte-for-byte; readable-but-uneditable frontmatter shapes refuse loudly instead of corrupting.
|
||||
- **Live store** — every open board is one shared, watched, in-memory snapshot: an FSEvents folder watcher (debounced, `.git`-filtered, origin-reconciling) drives whole-tree reloads with a generation guard and single-flight coalescing; write brackets suppress self-echo; a file-identity-keyed store registry refcounts stores and watchers across windows and absorbs root renames via bookmark re-resolution (a vanished root locks the board and watches for its return); plus the board registry (recents, bookmarks, cached counts), the banner center's single precedence order, the dirty-buffer guard, and transient UI state.
|
||||
- **Window architecture** — the three window types and their lifecycle: a welcome window (below), one board window per root (per-board frame memory, repositioned onto a live screen), and at-most-one card window per card (last-used size, cascaded; follows its card across lanes; dismisses on delete, tombstone, or cross-board move). Closing a board window or quitting runs one strict close flush — card sessions end, pending work drains, the registry is stamped — before the store tears down; launch restores the boards whose open-now flags survived quit (or crash), a preference gating only whether the flags are consulted.
|
||||
- **The board** — every lane always on screen, the window's width dividing across the lanes' width units with no horizontal scroll: cards flow into as many interior masonry columns as a lane is wide, a right-edge drag resizes between whole units by growing the *window* (snapping at the gap with release hysteresis, hard-stopping at the screen with rubber-band feedback), and ⌥⌘→/⌥⌘← re-divide the existing width instead. Lane chrome is a per-lane SF Symbol (unknown names fall back leniently), title or untitled placeholder, a card-count badge that counts exactly what's rendered, and a new-card button — the whole title bar doubling as the reorder drag surface, a plain click selecting the lane and movement carrying it above its siblings while they show the would-be order.
|
||||
- **The board** — every lane always on screen, the window's width dividing across the lanes' width units with no horizontal scroll: cards flow into as many interior masonry columns as a lane is wide, a right-edge drag resizes between whole units by growing the *window* (snapping at the gap with release hysteresis, hard-stopping at the screen with rubber-band feedback), and ⌥⌘→/⌥⌘← re-divide the existing width instead. Lane chrome is a per-lane SF Symbol (unknown names fall back leniently), title or untitled placeholder, a card-count badge that counts exactly what's rendered, and a new-card button — the whole title bar doubling as the drag surface, a plain click selecting the lane and movement carrying it away.
|
||||
- **Card faces** — a card reads as a leading SF Symbol, its title (or a quiet untitled placeholder), and a quiet paperclip when it has attachments — title-only by design, no body excerpt. Colour is an edge accent rather than a fill: `background` paints a stripe down the card's left edge and `iconColor` tints the symbol, both written as a kebab-case palette name (12 icon tints, 12 backgrounds) or a `#RRGGBB[AA]` hex. Everything degrades rather than complains — an unreadable colour simply doesn't paint, and the value stays on disk exactly as written. Each card's snapshot carries its attachment names, listed flat and in Finder order (top-level files only; subfolders, hidden files, and symlinks are preserved but never surfaced).
|
||||
- **Creating and renaming** — New Card (⌘N) files into the selected card's lane immediately after it, a selected lane's bottom, or the last-active lane, opening a focused pseudo-card that exists nowhere on disk until its title commits (Return commits and re-selects the lane, ⌘↩ also opens the card window, Escape or clicking away discards, and a failed create discards rather than waiting for a card that can't arrive). Inline rename — Return on a card, Board ▸ Rename for either kind — tracks its target by UUID, so a foreign move mid-edit is invisible and a tombstone or deletion discards the edit silently; committing empty removes the `title` key rather than writing a blank one. New Lane is ⇧⌘N. Every mutating command disables while an editor holds the keyboard and under the read-only lock.
|
||||
|
||||
- **Drag & drop** — cards, lanes and trash rows all travel as real system drag sessions, so a drag crosses window boundaries, shows the system's own copy badge, and carries a full-size replica of what it picked up. A dashed shadow sits at the exact landing spot and the board reflows to make room; the proposal is pure geometry over an analytically reconstructed resting layout — never measured mid-animation frames — so the shadow is stable rather than jittery, and a lane only reflows once the cursor reaches where the dragged run would actually land, holding its last proposal across the ambiguous stretch in between. Dragging any member of a multi-selection drags the whole selection: N contiguous shadows, one insertion point, landing in flatten order. Locality picks the default the way Finder's volumes do — within a board a drag moves, between boards it copies, with ⌥ forcing copy and ⌘ forcing move and the badge tracking live as the cursor crosses a boundary; a lane reordering inside its own board ignores ⌥ entirely, and a lane copy strips tombstoned cards while a lane move carries them whole. Dragging a trash row onto a lane restores it at the drop position, ⌥ copies it out live instead, and dropping it on another board follows the same copy-out grammar. Lanes taller than their viewport autoscroll from either edge, re-resolving the landing spot on every step so a stationary cursor still lands where the shadow shows. A foreign edit mid-drag re-grounds the drag rather than corrupting the drop: the zones re-derive against each new snapshot, a proposal whose lane was deleted withdraws and a release with none simply cancels, and a drag whose items all vanish dissolves itself. At release the board keeps drawing the dropped arrangement until the write round-trips through the watcher, so nothing snaps back for a frame; every drop is one write bracket — one reload, one commit — whatever the set's size.
|
||||
|
||||
- **The keyboard** — the board is fully operable without the mouse, and every board function has a menu item. Arrows walk to the nearest card in the direction — across a lane's interior masonry columns, across lanes, and into and out of the shown trash — with ⇧-arrows extending the range from the anchor and stopping dead at the liveness and card/lane-entry boundaries rather than silently reaching past them. ⌥-arrows jump: ⌥↑/⌥↓ to the lane's first/last card, ⌥←/⌥→ to the first/last lane (⌥→ reaching the trash when it's shown), and a second ⌥↑ on a lane's first card escalates into selecting the lane itself — the one keyboard entry to lane selection, from which ←/→ move between lanes, ↓ descends back into the cards, and an empty selection seeds at the first lane's first card so an arrow from nothing always means the same thing. The selection scrolls itself into view. The Board menu carries the rest: Open Card (⌘↩) — the one command that stays live mid-edit, committing the title and opening the window — Move Up/Move Down (⌥⌘↑/⌥⌘↓), which sort within a lane in logical order and gather a scattered selection into a block behind its first card on the first press, and Move Left/Move Right (⌘←/⌘→), which slide a selected lane one slot and never into the trash. Deleting picks the successor sibling Finder-style, so repeated ⌫ walks down a lane; an external deletion deliberately doesn't.
|
||||
|
||||
- **Styling** — one style editor serves every anchor: a background grid of the twelve palette wells behind a leading None well that *removes* the key, and a curated grid of five dozen kanban-relevant SF Symbols behind a leading level-default well that does the same. It is selection-aware (the selected cards or lanes; the board with nothing selected) and states the current value per dimension across the whole target set — agreement selects a well, disagreement reads "—", and a hand-written hex or uncurated symbol states itself verbatim outside the grids, replaced by any well you choose. A batch applies as one bracketed commit that skips every target already carrying the value, and the open editor tracks its targets live: one deleted out from under it leaves the set, and the last one closes the editor rather than quietly retargeting the board. Reached from Board ▸ Style… (⌥⌘S) or a card's or lane's context menu, where a compact row of app-wide recent colours recolours in one click and a lane's menu also carries its width stepper. Colour renders at all three levels — a card's `background` as a left-edge stripe, a lane's as a full-width band along its top edge, the board's as the window's content background — each painting nothing at all when the value doesn't resolve, bytes on disk untouched.
|
||||
|
||||
@@ -23,3 +23,7 @@ The card window's History section (DESIGN/05-card-window.md) is read-only in v1.
|
||||
### 5. Lane history
|
||||
|
||||
Per-lane commit trail, the lane sibling of the card History section. Undecided whether it's wanted at all (a lane's own commits — rename, restyle, resize, reorder — are sparse; most of what changes "in" a lane belongs to its cards) and where it would surface, since lanes have no window.
|
||||
|
||||
### 6. EchoLedger-synthesized foreign undo (base edition)
|
||||
|
||||
Narrow base Lanework's honest gap vs Pro — foreign/agent edits aren't ⌘Z-able without git (DESIGN/13-native-undo.md ▸ Out of scope). The EchoLedger already classifies every foreign diff for announcements; it could synthesize inverse operations and push foreign steps onto the native stack, making an agent's edit undoable in base too. Real design needed: ordering against app steps, attribution in menu titles, user expectations when undoing work they didn't do, interaction with the staleness rules.
|
||||
|
||||
Reference in New Issue
Block a user