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