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:
+182
-175
@@ -17,10 +17,12 @@ import SwiftUI
|
||||
///
|
||||
/// ### The three interactions it hosts
|
||||
///
|
||||
/// - **Lane resize** — the right-edge grab strip (above).
|
||||
/// - **Lane reorder** — the whole title bar is the drag surface (`LaneReorderSession`,
|
||||
/// `LaneReorderMath`); the travelling lane rides above its siblings while they show the would-be
|
||||
/// order.
|
||||
/// - **Lane resize** — the right-edge grab strip (above). Deliberately *not* a drag session
|
||||
/// (DRAG-REORDER.md § Adjacent interaction).
|
||||
/// - **Drag & drop** — cards, lanes and trash rows travel as **system drag sessions**, which is what
|
||||
/// crosses window boundaries, draws the copy badge and gives the full-size replica
|
||||
/// (`DragSession`, `BoardDrops.swift`, DRAG-REORDER.md). The strip owns the drop geometry
|
||||
/// registry and the strip-level drop target; the lanes own theirs.
|
||||
/// - **The rubber band** — a drag from any empty surface sweeps a selection (`MarqueeSession`,
|
||||
/// `MarqueeMath`); the strip owns the session and the target registry, and hands both down.
|
||||
/// - **The board's fixed grammar keys** (11-command-nexus.md ▸ Fixed grammar keys) — the four
|
||||
@@ -34,8 +36,8 @@ import SwiftUI
|
||||
///
|
||||
/// ### What is deliberately not here yet
|
||||
///
|
||||
/// The toolbar, search, and drag & drop's real machinery (multi-drag, cross-board locality, the
|
||||
/// shadow's hold rule) all belong to later milestone cards.
|
||||
/// The toolbar and search belong to later milestone cards; so do external Finder file drops, which
|
||||
/// join the very drop delegates this file already attaches (see `BoardDrops.swift`).
|
||||
struct BoardView: View {
|
||||
|
||||
let store: BoardStore
|
||||
@@ -62,11 +64,10 @@ struct BoardView: View {
|
||||
/// view does, which is the interaction's whole lifetime.
|
||||
@State private var resize = LaneResizeSession()
|
||||
|
||||
/// One reorder at a time, per window — same lifetime, same reasoning.
|
||||
@State private var reorder = LaneReorderSession()
|
||||
|
||||
/// One drag out of the trash at a time, per window — same lifetime again.
|
||||
@State private var trashDrag = TrashDragSession()
|
||||
/// Where this window's lanes draw their card grids and how tall their cards are — the measured
|
||||
/// half of a card drop's geometry, plus the strip's own frame (`LaneDropRegistry`). `@State` for
|
||||
/// the resize session's reason: one per window, living exactly as long as the window.
|
||||
@State private var laneDrops = LaneDropRegistry()
|
||||
|
||||
/// One rubber band at a time, per window (`MarqueeSession`).
|
||||
@State private var marquee = MarqueeSession()
|
||||
@@ -94,36 +95,15 @@ struct BoardView: View {
|
||||
private let spacing: CGFloat = 12
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { viewport in
|
||||
let lanes = liveLanes
|
||||
// During a resize session the standard is FROZEN at its drag-start value: the window is
|
||||
// animating mid-resize, so deriving the standard from the live viewport width would feed
|
||||
// that animation back into every lane and pulse the whole strip. The window is sized on
|
||||
// each tick so this frozen value equals what the viewport formula yields once the
|
||||
// session ends — the handoff is seamless (see `LaneResizeSession`).
|
||||
let standard = resize.isActive
|
||||
? resize.standard
|
||||
: LaneLayoutMath.standardWidth(
|
||||
stripWidth: viewport.size.width,
|
||||
// The trash's one fixed unit joins the division **only while shown**, which is
|
||||
// the whole of "Show/Hide Trash is a re-divide trigger" (03-board-ui.md § Trash):
|
||||
// the window is never touched, the existing width simply divides across one more
|
||||
// unit and every lane compresses — a lane add's behaviour, exactly.
|
||||
totalUnits: LaneLayoutMath.totalUnits(of: lanes, trashUnits: isTrashVisible ? 1 : 0),
|
||||
gap: spacing)
|
||||
// The drag's drop proposal, computed once because two things read it: the order the
|
||||
// strip shows, and the key its reflow animates on. Recomputed on every render, so a
|
||||
// foreign reload mid-drag simply moves the zones (rule 1 of 04-interactions.md ▸ Drag
|
||||
// and drop's re-grounding trio).
|
||||
let move = proposal(among: lanes, standard: standard)
|
||||
// The lanes in the order the strip should *show* them: their snapshot order at rest, and
|
||||
// the drag's would-be order while a reorder is in flight — which is how the siblings
|
||||
// reflow to make room. A drag whose lane has vanished from the snapshot proposes nothing
|
||||
// and shows the plain order; its release then cancels ("an emptied drag cancels itself").
|
||||
let shown = move.map { LaneReorderMath.reordered(lanes, from: $0.from, to: $0.to) } ?? lanes
|
||||
GeometryReader { _ in
|
||||
// The strip's slots: the lanes the strip should *show*, with the drag's N contiguous
|
||||
// shadows opened at the proposal. Recomputed on every render, so a foreign reload
|
||||
// mid-drag simply moves the zones (rule 1 of 04-interactions.md ▸ Drag and drop's
|
||||
// re-grounding trio) — nothing about a drag is cached across a snapshot.
|
||||
let slots = stripSlots
|
||||
ZStack(alignment: .topLeading) {
|
||||
backdrop
|
||||
laneStrip(shown, standard: standard, move: move)
|
||||
laneStrip(slots)
|
||||
}
|
||||
.padding(spacing)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
@@ -132,14 +112,38 @@ struct BoardView: View {
|
||||
// marquee in the animation-free-by-construction list ("1:1 cursor following — animating
|
||||
// input echo would be lag").
|
||||
.overlay(alignment: .topLeading) { marqueeBand }
|
||||
// The space a drop out of the trash is resolved in — see `BoardView.stripSpace`. It goes
|
||||
// on the padded container so x = 0 is the strip's leading edge with the outer margin
|
||||
// included, which is the origin `LaneLayoutMath`'s arithmetic assumes. Every marquee
|
||||
// coordinate — the band's own drag samples and each registered item frame — is measured
|
||||
// here too, so nothing ever converts between spaces.
|
||||
// The space the marquee is resolved in — see `BoardView.stripSpace`. It goes on the
|
||||
// padded container so x = 0 is the strip's leading edge with the outer margin included,
|
||||
// which is the origin `LaneLayoutMath`'s arithmetic assumes. Every marquee coordinate —
|
||||
// the band's own drag samples and each registered item frame — is measured here too, so
|
||||
// nothing ever converts between spaces.
|
||||
.coordinateSpace(.named(Self.stripSpace))
|
||||
// The same rectangle in the window's *global* space, which is where the physical cursor
|
||||
// lands once converted (`BoardDropContext.globalCursor`). Written into the registry
|
||||
// rather than into `@State` so the drop delegates read it live at event time rather than
|
||||
// as of the last body evaluation.
|
||||
.onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { laneDrops.stripFrame = $0 }
|
||||
// **The strip's drop target** — the backdrop, the gaps, the outer margin, and the trash
|
||||
// column's footprint, which is never a landing spot of its own (04-interactions.md ▸ The
|
||||
// trash) and so simply falls through to here. It accepts *both* board types and routes
|
||||
// internally, because single-target dispatch has no fall-through (DRAG-REORDER.md).
|
||||
.onDrop(of: boardDragTypes, delegate: StripDropDelegate(context: dropContext))
|
||||
}
|
||||
.background(boardBackground)
|
||||
// The window-level fallback, *behind* the specific targets: a release over any in-window
|
||||
// region they don't cover commits the current proposal instead of leaking the session into a
|
||||
// cancel-snapback — the drop lands where the shadows show, which is what the shadows promise.
|
||||
.background {
|
||||
Color.clear
|
||||
.onDrop(of: boardDragTypes, delegate: BoardFallbackDropDelegate(context: dropContext))
|
||||
}
|
||||
// **The committed-overlay hold's hand-off** (DRAG-REORDER.md § The committed-overlay hold):
|
||||
// the overlay stands in for an arrangement that is on disk but not yet in the snapshot, and
|
||||
// discards itself the moment a snapshot lands — because holding a moment longer would draw
|
||||
// the arrangement twice.
|
||||
.onChange(of: store.snapshotGeneration) { _, generation in
|
||||
appModel.dragSession.handOff(root: store.rootURL, generation: generation)
|
||||
}
|
||||
.trashPurgeAlert(store: store, confirmations: confirmations)
|
||||
// The board's own anchor for the Style… popover — the surface a board-targeted session hangs
|
||||
// off, since the board has no item to attach to (`styleEditorPresentation`'s `nil` anchor).
|
||||
@@ -174,6 +178,19 @@ struct BoardView: View {
|
||||
// forbids outright (04 ▸ Configurable bindings). A focused text field consumes it first, so
|
||||
// ⌘A inside an inline editor stays text selection with no guard needed here.
|
||||
.onCommand(#selector(NSText.selectAll(_:))) { store.selectAll() }
|
||||
// **Belt** for a session SwiftUI does report ending: immediate cleanup, gated on the physical
|
||||
// mouse button being up. A finished session's phase events can arrive *after* the user has
|
||||
// started the next drag, and an ungated handler would wipe the new session's state — no
|
||||
// shadow, drop dead. `DragSession`'s watchdog is the braces (see `armWatchdog`), and it is
|
||||
// what makes refusing here free.
|
||||
.onDragSessionUpdated { session in
|
||||
switch session.phase {
|
||||
case .ended, .dataTransferCompleted:
|
||||
MainActor.assumeIsolated { appModel.dragSession.endIfButtonReleased() }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The strip's layers
|
||||
@@ -194,28 +211,38 @@ struct BoardView: View {
|
||||
.simultaneousGesture(marqueeControl.gesture(side: .live))
|
||||
}
|
||||
|
||||
/// The lanes themselves, plus the trash column when it is shown.
|
||||
/// The lanes and the drag's shadows, plus the trash column when it is shown.
|
||||
@ViewBuilder
|
||||
private func laneStrip(_ shown: [Lane], standard: CGFloat, move: (from: Int, to: Int)?) -> some View {
|
||||
private func laneStrip(_ slots: [StripSlot]) -> some View {
|
||||
let standard = standardWidth
|
||||
HStack(alignment: .top, spacing: spacing) {
|
||||
ForEach(Array(shown.enumerated()), id: \.element.id) { position, lane in
|
||||
laneSlot(lane, at: position, among: shown, standard: standard)
|
||||
// "Appear/disappear is scale + fade … lanes ~0.9" (03-board-ui.md § Motion).
|
||||
// A create, a delete and a Put Back all reach the strip as a lane arriving in
|
||||
// or leaving this `ForEach`; whether that *performs* is decided upstream, at
|
||||
// the reload that carried it (`Motion.reloadAnimates`) — a transition with no
|
||||
// animated transaction around it is simply an appearance.
|
||||
.transition(Motion.laneTransition(reduced: reduceMotion))
|
||||
ForEach(slots) { slot in
|
||||
switch slot {
|
||||
case let .lane(lane):
|
||||
laneSlot(lane, standard: standard)
|
||||
// "Appear/disappear is scale + fade … lanes ~0.9" (03-board-ui.md § Motion).
|
||||
// A create, a delete and a Put Back all reach the strip as a lane arriving in
|
||||
// or leaving this `ForEach`; whether that *performs* is decided upstream, at
|
||||
// the reload that carried it (`Motion.reloadAnimates`) — a transition with no
|
||||
// animated transaction around it is simply an appearance.
|
||||
.transition(Motion.laneTransition(reduced: reduceMotion))
|
||||
case let .shadow(_, units):
|
||||
// One of the drag's N contiguous shadows, at the exact width the arriving lane
|
||||
// will occupy — its units measured against *this* strip's standard, which is
|
||||
// what makes the drop land precisely where the shadow shows.
|
||||
DragShadow()
|
||||
.frame(width: LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing))
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
if isTrashVisible {
|
||||
// Trailing, always — the quasi-lane has no position of its own to lose, which is
|
||||
// also why it never appears in the reorder proposal's inputs (those are built
|
||||
// from `liveLanes`).
|
||||
// also why it never appears in the drop proposal's inputs (those are built from
|
||||
// `liveLanes`) and why the terminal slot clamps in front of it.
|
||||
TrashLaneView(
|
||||
store: store,
|
||||
confirmations: confirmations,
|
||||
drag: TrashRowDrag { x in laneUnder(x: x, standard: standard) },
|
||||
dragSession: trashDrag,
|
||||
drops: dropContext,
|
||||
marquee: marqueeControl
|
||||
)
|
||||
.frame(width: LaneLayoutMath.slotWidth(units: 1, standard: standard, gap: spacing))
|
||||
@@ -229,15 +256,14 @@ struct BoardView: View {
|
||||
}
|
||||
// The drag's reflow-to-make-room, keyed on the **drop proposal** and nothing else
|
||||
// (03-board-ui.md § Motion: transactions are keyed narrowly, "on the drag's drop
|
||||
// proposal … never on broad state"). The travelling lane's own offset changes on every
|
||||
// pointer sample and none of those samples touch this value, so the replica keeps
|
||||
// tracking the cursor 1:1 — which is the same bullet's other half. At the instant the
|
||||
// proposal ticks, the lane's slot and its offset move by equal and opposite amounts, so
|
||||
// animating both under one curve is what keeps it pinned under the cursor.
|
||||
// proposal … never on broad state"). Narrowed further to the *strip's* proposal: a card
|
||||
// session moving its shadow inside a lane must not re-time the whole strip. The replica's
|
||||
// own tracking is the system drag image's and touches nothing here, which is the same
|
||||
// bullet's other half — animating input echo would be lag.
|
||||
//
|
||||
// It stays on the `HStack` rather than moving out to the `ZStack`, so the marquee band
|
||||
// drawn beside it is never inside an animated transaction (03 § Motion again).
|
||||
.animation(Motion.dragReflow(reduced: reduceMotion), value: move?.to)
|
||||
.animation(Motion.dragReflow(reduced: reduceMotion), value: stripProposal)
|
||||
}
|
||||
|
||||
/// The rubber band itself: a translucent accent fill with a hairline border, in strip
|
||||
@@ -303,22 +329,19 @@ struct BoardView: View {
|
||||
/// The outer frame is always the snapped slot width, so the `HStack` lays the other lanes out
|
||||
/// off the tidy snapped layout regardless of the live overflow.
|
||||
///
|
||||
/// While this lane is being **reordered** the slot instead keeps its resting size and travels:
|
||||
/// the offset is the gap between where the pointer has carried it and where it would rest under
|
||||
/// the current proposal, so it tracks the cursor 1:1 while its siblings sit in the would-be
|
||||
/// order beneath it (`zIndex(2)`, above even a resize).
|
||||
/// A lane being **dragged** is simply absent from the strip: it is lifted out of the resting
|
||||
/// layout at pickup and stays out until release, whatever the effective operation is
|
||||
/// (DRAG-REORDER.md § Resting-layout zones), while the system drag session carries its replica.
|
||||
@ViewBuilder
|
||||
private func laneSlot(_ lane: Lane, at position: Int, among shown: [Lane], standard: CGFloat) -> some View {
|
||||
private func laneSlot(_ lane: Lane, standard: CGFloat) -> some View {
|
||||
let resizing = resize.isResizing(lane.id)
|
||||
let dragging = reorder.isDragging(lane.id)
|
||||
let units = resizing ? resize.units : LaneLayoutMath.displayUnits(of: lane)
|
||||
let slotWidth = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing)
|
||||
ZStack(alignment: .topLeading) {
|
||||
if resizing {
|
||||
LaneResizeShadow()
|
||||
DragShadow(dashed: false)
|
||||
.frame(width: slotWidth)
|
||||
.frame(maxHeight: .infinity)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
// Interior columns follow the SNAPPED unit count while this lane is being resized — a
|
||||
// column count is integral, so it tracks k (which ticks and animates), not the live
|
||||
@@ -329,27 +352,15 @@ struct BoardView: View {
|
||||
store: store,
|
||||
lane: lane,
|
||||
columns: units,
|
||||
reorder: reorder,
|
||||
headerDrag: headerDrag(at: position, among: shown, standard: standard),
|
||||
slotWidth: slotWidth,
|
||||
drops: dropContext,
|
||||
marquee: marqueeControl,
|
||||
openCard: openCard
|
||||
)
|
||||
.frame(width: resizing ? resize.liveWidth : slotWidth, alignment: .leading)
|
||||
}
|
||||
// The drop highlight for a drag out of the trash: the lane the pointer is currently over.
|
||||
// Feedback lives on the *target* rather than on a travelling replica, because the replica —
|
||||
// its lift, its settle, the copy/move badge — is m5's drag card (03-board-ui.md § Motion).
|
||||
.overlay {
|
||||
if trashDrag.isTarget(lane.id) {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.strokeBorder(Color.accentColor, lineWidth: 2)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.frame(width: slotWidth, alignment: .topLeading)
|
||||
.offset(x: dragging ? travelOffset(at: position, among: shown, standard: standard) : 0)
|
||||
.opacity(dragging ? 0.9 : 1)
|
||||
.zIndex(dragging ? 2 : (resizing ? 1 : 0))
|
||||
.zIndex(resizing ? 1 : 0)
|
||||
.overlay(alignment: .trailing) {
|
||||
LaneResizeHandle(
|
||||
store: store,
|
||||
@@ -365,9 +376,10 @@ struct BoardView: View {
|
||||
// resizes the *window* on the way, so a refused commit would leave the window grown
|
||||
// around a lane that snapped back — and the lock's row is already saying why nothing
|
||||
// can be written. The focused-editor rule closes it too, like every board command, and
|
||||
// so does a reorder in flight: two drags mutating one strip layout is not a state this
|
||||
// view has a meaning for.
|
||||
.disabled(store.isReadOnly || store.isEditingInline || reorder.isActive)
|
||||
// so does a drag session in flight — the edge drag "refuses to start while a card/lane
|
||||
// session is in flight" (DRAG-REORDER.md § Adjacent interaction): two gestures mutating
|
||||
// one strip layout is not a state this view has a meaning for.
|
||||
.disabled(store.isReadOnly || store.isEditingInline || appModel.dragSession.isActive)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,90 +399,99 @@ struct BoardView: View {
|
||||
store.transient.isTrashVisible
|
||||
}
|
||||
|
||||
/// The live lane under `x` in strip coordinates, or `nil` — the strip's half of drag-to-restore.
|
||||
///
|
||||
/// Re-derived against `liveLanes` at gesture time rather than captured at drag start, which is
|
||||
/// 04-interactions.md ▸ Drag and drop's re-grounding rule: a foreign reload that adds or
|
||||
/// tombstones a lane mid-drag just moves the zones, and the next proposal targets the board as it
|
||||
/// now is. A tombstoned lane is never a drop target because it is never in this list.
|
||||
private func laneUnder(x: CGFloat, standard: CGFloat) -> ItemID? {
|
||||
let lanes = liveLanes
|
||||
guard let index = LaneLayoutMath.laneIndex(
|
||||
atX: x,
|
||||
unitCounts: unitCounts(of: lanes),
|
||||
standard: standard,
|
||||
gap: spacing
|
||||
) else { return nil }
|
||||
return lanes.indices.contains(index) ? lanes[index].id : nil
|
||||
}
|
||||
// MARK: - The drag
|
||||
|
||||
// MARK: - Reorder
|
||||
|
||||
/// Where the dragged lane sits in `lanes` and where it would land — `nil` when no reorder is in
|
||||
/// flight, or when the lane it is carrying is no longer on the board.
|
||||
/// What each of this window's drop targets — and its lanes' autoscroll drivers — is handed.
|
||||
///
|
||||
/// Called **once** per render, because a proposal is two things at once and they must be the
|
||||
/// same answer: the order the strip shows, and the narrow key its reflow animates on (see
|
||||
/// `body`). It is asked again at release, against the snapshot as it is by then
|
||||
/// (`commitReorder`).
|
||||
private func proposal(among lanes: [Lane], standard: CGFloat) -> (from: Int, to: Int)? {
|
||||
guard let id = reorder.laneID, let from = lanes.firstIndex(where: { $0.id == id }) else { return nil }
|
||||
let to = LaneReorderMath.proposedIndex(
|
||||
unitCounts: unitCounts(of: lanes),
|
||||
draggedIndex: from,
|
||||
dragCentreX: reorder.centre,
|
||||
standard: standard,
|
||||
gap: spacing
|
||||
/// Every geometric input is a **closure**, read at event time (`BoardDropContext`): a captured
|
||||
/// snapshot of the strip's frame or its standard width goes stale the moment the layout animates,
|
||||
/// and two delegates holding different snapshots would flap the proposal between them.
|
||||
private var dropContext: BoardDropContext {
|
||||
BoardDropContext(
|
||||
store: store,
|
||||
session: appModel.dragSession,
|
||||
registry: laneDrops,
|
||||
gap: spacing,
|
||||
window: window,
|
||||
stripFrame: { laneDrops.stripFrame },
|
||||
standard: { standardWidth }
|
||||
)
|
||||
return (from, to)
|
||||
}
|
||||
|
||||
/// How far the travelling lane is drawn from the slot it would rest in — the pointer's position
|
||||
/// minus the proposal's. Zero at the instant a tick lands, growing again as the pointer moves
|
||||
/// on, which is what makes the replica read as *held* rather than as snapping.
|
||||
private func travelOffset(at position: Int, among shown: [Lane], standard: CGFloat) -> CGFloat {
|
||||
reorder.centre - LaneReorderMath.centre(
|
||||
ofLaneAt: position,
|
||||
unitCounts: unitCounts(of: shown),
|
||||
standard: standard,
|
||||
/// The strip's 1× lane width.
|
||||
///
|
||||
/// During a resize session the standard is **frozen** at its drag-start value: the window is
|
||||
/// animating mid-resize, so deriving the standard from the live width would feed that animation
|
||||
/// back into every lane and pulse the whole strip. The window is sized on each tick so this frozen
|
||||
/// value equals what the formula yields once the session ends — the handoff is seamless (see
|
||||
/// `LaneResizeSession`).
|
||||
///
|
||||
/// Otherwise it is the ordinary division, over three contributions:
|
||||
///
|
||||
/// - the **live lanes**' units. A lane in flight is still a lane on the board, so a within-board
|
||||
/// drag does *not* re-divide the strip: "the shadow occupies its units" (DRAG-REORDER.md § The
|
||||
/// lane strip's resting layout is arithmetic). Recomputing at pickup and again at release is
|
||||
/// exactly the motion-feeds-back-into-logic failure the model exists to avoid.
|
||||
/// - the **trash's** one fixed unit, *only while shown* — the whole of "Show/Hide Trash is a
|
||||
/// re-divide trigger" (03-board-ui.md § Trash): the window is never touched, the existing width
|
||||
/// simply divides across one more unit and every lane compresses.
|
||||
/// - a **cross-board lane arrival**'s units while its shadow hovers here, by the same rule read
|
||||
/// from the destination's side: the shadow occupies its units, and the strip has to make room
|
||||
/// for them or the shadow would be drawn at a width the lane will not have.
|
||||
private var standardWidth: CGFloat {
|
||||
if resize.isActive { return resize.standard }
|
||||
var units = LaneLayoutMath.totalUnits(of: liveLanes, trashUnits: isTrashVisible ? 1 : 0)
|
||||
units += arrivingLaneUnits
|
||||
return LaneLayoutMath.standardWidth(
|
||||
stripWidth: laneDrops.stripFrame.width,
|
||||
totalUnits: units,
|
||||
gap: spacing
|
||||
)
|
||||
}
|
||||
|
||||
/// The strip's half of a lane header's drag: where the lane rests now, and what a release means.
|
||||
private func headerDrag(at position: Int, among shown: [Lane], standard: CGFloat) -> LaneHeaderDrag {
|
||||
LaneHeaderDrag(
|
||||
startCentre: {
|
||||
LaneReorderMath.centre(
|
||||
ofLaneAt: position,
|
||||
unitCounts: unitCounts(of: shown),
|
||||
standard: standard,
|
||||
gap: spacing
|
||||
)
|
||||
},
|
||||
commit: { commitReorder(standard: standard) }
|
||||
)
|
||||
/// The units a cross-board lane run would add to this strip while its shadow is proposed here;
|
||||
/// zero for a within-board drag, whose lanes are already counted.
|
||||
private var arrivingLaneUnits: Int {
|
||||
let session = appModel.dragSession
|
||||
guard session.isDraggingLanes,
|
||||
session.stripProposal(onBoardRooted: store.rootURL) != nil,
|
||||
let source = session.sourceRoot,
|
||||
!DragLocality.isSameBoard(source, store.rootURL)
|
||||
else { return 0 }
|
||||
return session.laneUnits.reduce(0, +)
|
||||
}
|
||||
|
||||
/// Releases the drag: re-derive the proposal against the snapshot **as it is now** and write it.
|
||||
///
|
||||
/// Re-deriving rather than trusting the last rendered proposal is 04-interactions.md ▸ Drag and
|
||||
/// drop's re-grounding rule at its most consequential moment: a reload that landed between the
|
||||
/// last render and the release must not be written over. A lane that vanished in that window
|
||||
/// yields no proposal and the release simply cancels — "release with no valid proposal cancels;
|
||||
/// items return, nothing is written".
|
||||
///
|
||||
/// `BoardStore.moveLane` owns the rest, the unchanged-index no-op included.
|
||||
private func commitReorder(standard: CGFloat) {
|
||||
defer { reorder.end() }
|
||||
guard let id = reorder.laneID,
|
||||
let (_, to) = proposal(among: liveLanes, standard: standard)
|
||||
else { return }
|
||||
store.moveLane(id, toIndex: to)
|
||||
/// The strip's current lane-drop proposal — the reflow's narrow animation key, and where the
|
||||
/// shadow run opens.
|
||||
private var stripProposal: Int? {
|
||||
appModel.dragSession.stripProposal(onBoardRooted: store.rootURL)
|
||||
}
|
||||
|
||||
private func unitCounts(of lanes: [Lane]) -> [Int] {
|
||||
lanes.map { LaneLayoutMath.displayUnits(of: $0) }
|
||||
/// One position in the strip: a lane, or one of the drag's N contiguous shadows.
|
||||
private enum StripSlot: Identifiable {
|
||||
case lane(Lane)
|
||||
case shadow(index: Int, units: Int)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case let .lane(lane): "lane:\(lane.id.rawValue)"
|
||||
// Constant per position, so a shadow run keeps its identity as the proposal slides and
|
||||
// the run animates as a move rather than blinking out and back in.
|
||||
case let .shadow(index, _): "shadow:\(index)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the strip lays out: the resting lanes — the dragged run lifted out, whatever the
|
||||
/// effective operation is — with the shadows opened at the proposal.
|
||||
private var stripSlots: [StripSlot] {
|
||||
let session = appModel.dragSession
|
||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootURL)
|
||||
var slots = liveLanes.filter { !hidden.contains($0.id) }.map(StripSlot.lane)
|
||||
guard let index = stripProposal else { return slots }
|
||||
let shadows = session.laneUnits.enumerated().map { StripSlot.shadow(index: $0.offset, units: $0.element) }
|
||||
slots.insert(contentsOf: shadows, at: min(max(0, index), slots.count))
|
||||
return slots
|
||||
}
|
||||
|
||||
// MARK: - Grammar keys
|
||||
@@ -866,17 +887,3 @@ struct BoardView: View {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Resize shadow
|
||||
|
||||
/// The resting footprint a lane snaps back to, drawn behind the live lane during a resize.
|
||||
///
|
||||
/// Minimal on purpose: 03-board-ui.md's placeholder/drag vocabulary lands with the drag milestone,
|
||||
/// and this is the same shape that card and lane drops will want. Kept here rather than invented
|
||||
/// twice.
|
||||
private struct LaneResizeShadow: View {
|
||||
var body: some View {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(.quaternary.opacity(0.5))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user