Files
lanework/Kanban/UI/Board/SelectionClicks.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
5.4 KiB
Swift

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.
///
/// `BoardDropContext`'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 {
// Neither cursor: a band names no click to range from and no item to arrow from,
// so a ⇧-click after one acts plain and an arrow re-derives a position from the
// set's last member (`TransientBoardState.selectionAnchor`, `selectionHead`).
// Both are spelled out rather than defaulted, because `select`'s sole-member
// default would otherwise pick one up the moment a band happened to sweep
// exactly one card.
store.select(ids, liveness: session.side, anchor: nil, head: 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) }
}
}