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 } }