Files
lanework/Kanban/UI/Board/MarqueeSession.swift
T
rzen 90cf82d740 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
2026-07-27 20:58:26 -04:00

112 lines
4.8 KiB
Swift

import CoreGraphics
import Observation
// MARK: - MarqueeSession
/// 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`).
///
/// **The side is frozen at the origin** — "a rubber-band stays on the side of the boundary it
/// started on" (04-interactions.md ▸ The trash). A band begun on the board and dragged across the
/// trash column keeps selecting live cards; a band begun in the trash keeps selecting rows. That is
/// the whole reason the side is stored here rather than re-derived from what the rect touches.
///
/// **Nothing here animates.** 03-board-ui.md § Motion names the marquee twice: "the rubber-band
/// marquee tracks the cursor 1:1 (an eased band visibly lags the mouse)" and, in the
/// animation-free-by-construction list, "the marquee rectangle (1:1 cursor following — animating
/// input echo would be lag)". So the rect is drawn outside every animated transaction on the strip,
/// and the selection churn it drives is deliberately un-keyed too ("never on broad state like the
/// selection set").
@MainActor
@Observable
final class MarqueeSession {
/// Where the band was begun, in `BoardView.stripSpace` coordinates; `nil` when idle — which is
/// what "no marquee" means here rather than a separate flag.
private(set) var origin: CGPoint?
/// The pointer's current position, in the same space. Meaningless while `origin` is `nil`.
private(set) var current: CGPoint = .zero
/// The side of the live/trash boundary this band selects on, frozen at `begin`.
private(set) var side: Liveness = .live
/// How far the pointer must travel before a drag on empty space becomes a band. Larger than the
/// lane header's threshold because this gesture arms on *any* empty surface, and a click that
/// clears the selection must not leave a one-pixel band behind it.
static let minimumDistance: CGFloat = 5
var isActive: Bool { origin != nil }
/// The band, normalized so it is a rectangle regardless of which way the drag ran; `nil` when
/// idle, which is also the overlay's "draw nothing".
var rect: CGRect? {
guard let origin else { return nil }
return CGRect(
x: min(origin.x, current.x),
y: min(origin.y, current.y),
width: abs(current.x - origin.x),
height: abs(current.y - origin.y)
)
}
func begin(at point: CGPoint, side: Liveness) {
origin = point
current = point
self.side = side
}
func update(to point: CGPoint) {
guard isActive else { return }
current = point
}
/// Ends the band. **The selection stays exactly as the last sample computed it** — a release is
/// not a commit here, because nothing was written: the band was only ever a way of naming a set.
/// Idempotent, like every other session's `end`.
func end() {
origin = nil
current = .zero
}
}
// MARK: - MarqueeTargetRegistry
/// Where each sweepable item is drawn, keyed by identity — the geometry half of the rubber band.
///
/// **The views register themselves** (`onGeometryChange` in `BoardView.stripSpace`, removal in
/// `onDisappear`) rather than the band re-deriving the masonry's arithmetic: the layout already
/// computed those frames, and a second computation is a second answer that could disagree with what
/// is on screen. It is also what makes the band correct for free across a lane resize, a reorder in
/// flight, and a foreign reload — the frames simply re-register.
///
/// **Lanes are never registered.** The band selects cards, and trash rows on the trash side; a lane
/// has no entry here at all, which is 04-interactions.md § Selection's "click-drag rubber-bands
/// across lanes" made structural rather than filtered.
///
/// It is also the **begin guard's** universe: a drag that starts inside a registered frame belongs
/// to that item's own gesture (a card drag, a drag out of the trash), never to the band. Deciding
/// that geometrically rather than by gesture priority is what keeps the two from fighting.
@MainActor
@Observable
final class MarqueeTargetRegistry {
private(set) var targets: [ItemID: MarqueeTarget] = [:]
var all: [MarqueeTarget] { Array(targets.values) }
func update(_ target: MarqueeTarget) {
targets[target.id] = target
}
func remove(_ id: ItemID) {
targets.removeValue(forKey: id)
}
/// Whether `point` lands on something already drawn — the band's begin guard.
func contains(_ point: CGPoint) -> Bool {
targets.values.contains { $0.frame.contains(point) }
}
}