import AppKit import SwiftUI // MARK: - The modifier a click carried extension ClickModifier { /// The modifier the keyboard is holding **right now**, reduced to the grammar's three cases. /// /// Read from `NSEvent.modifierFlags` rather than from the gesture value, because SwiftUI's /// `TapGesture` hands its handler nothing about the event — and `EventModifiers` on a /// `.modifiers(_:)`-qualified gesture would need one recogniser per modifier, three of which /// would then race to consume the same click. /// /// **⌘ wins over ⇧** when both are down: 04-interactions.md gives the two no combined meaning /// ("⌘-click toggles; ⇧-click range-extends"), so the reduction happens once, here, and no call /// site re-decides it. @MainActor static var current: ClickModifier { let flags = NSEvent.modifierFlags if flags.contains(.command) { return .command } if flags.contains(.shift) { return .shift } return .plain } } // MARK: - The rubber band's gesture /// 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 /// 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. @MainActor struct MarqueeControl { let session: MarqueeSession let registry: MarqueeTargetRegistry let store: BoardStore /// The band, as one gesture attached with `simultaneousGesture` wherever empty space is. /// /// - **The begin guard is geometric**: a drag whose start lands inside a registered frame is /// somebody else's (a card drag, a drag out of the trash), so no band begins and the sample /// loop simply keeps declining for the rest of that drag. Deciding this by frames rather than /// by gesture priority is what keeps the two from fighting, and it stays correct as the /// masonry reflows. /// - **The side is fixed at the origin** — 04-interactions.md ▸ The trash's rule, stored in the /// session so a band dragged across the boundary keeps its meaning. /// - **Live-updating, not commit-on-release**: each sample recomputes the whole set from the /// band, so the selection follows the cursor both ways. An empty band clears rather than /// leaving the last non-empty one standing. /// - **Alive under the read-only lock**: selection is not a mutation (02-architecture.md § The /// lock's scope), and no `isEditingInline` guard either — a click-away mid-rename already /// commits through the field's own focus loss. func gesture(side: Liveness) -> some Gesture { DragGesture(minimumDistance: MarqueeSession.minimumDistance, coordinateSpace: .named(BoardView.stripSpace)) .onChanged { value in if !session.isActive { guard !registry.contains(value.startLocation) else { return } session.begin(at: value.startLocation, side: side) } session.update(to: value.location) guard let rect = session.rect else { return } let ids = MarqueeMath.selection(rect: rect, targets: registry.all, side: session.side) if ids.isEmpty { store.clearSelection() } else { // No anchor: a band names no click to range from, so a ⇧-click after one acts // plain (`TransientBoardState.selectionAnchor`). store.select(ids, liveness: session.side, anchor: nil) } } .onEnded { _ in session.end() } } } // MARK: - Registering a sweepable frame extension View { /// Keeps this item's drawn frame in the window's marquee registry, and takes it out again when /// the view goes away. /// /// The frame is measured in `BoardView.stripSpace`, the one space every marquee coordinate lives /// in — the band's own points come from a drag gesture in the same space, so no conversion /// happens anywhere. @MainActor func marqueeTarget( _ id: ItemID, kind: SelectionKind, side: Liveness, in registry: MarqueeTargetRegistry ) -> some View { // The space name is read here, on the main actor, rather than inside the measuring closure: // `BoardView` is main-actor-isolated by its `View` conformance, and the closure is not. let space = BoardView.stripSpace return onGeometryChange(for: CGRect.self) { proxy in proxy.frame(in: .named(space)) } action: { frame in registry.update(MarqueeTarget(id: id, kind: kind, side: side, frame: frame)) } .onDisappear { registry.remove(id) } } }