Measured on real events 2026-08-07, correcting the 2026-08-06 hosted finding: a bare count-1 tap on LaneView's empty-space layer fires in ~1-3 ms with no drag source at all — the hold that made the empty .onDrag look necessary was the sterile NSApp.postEvent stream over-disambiguating. And the provider was actively harmful: even an empty drag source claims the mouse-drag at threshold, starving the marquee's simultaneous DragGesture after one sample — the band froze and the mouseUp never arrived. The layer goes dragless; drags from empty space belong wholly to MarqueeControl. PointerClick's and the layer's comments retell the corrected story. Alongside: openCard is typed @MainActor throughout, which makes the closure Sendable and lets CardFaceRole carry it under CardFaceView's nonisolated ==. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
179 lines
9.7 KiB
Swift
179 lines
9.7 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 click a handler is riding
|
||
|
||
/// Which click of a multi-click run the current gesture handler is riding — `NSEvent.clickCount`
|
||
/// off the event being dispatched, read the way `ClickModifier.current` reads the keyboard:
|
||
/// SwiftUI's `TapGesture` hands its handler nothing about the event.
|
||
///
|
||
/// **This is how a surface without a drag source gets a double-click meaning** (the 2026-08-06
|
||
/// click-latency fix). A second tap recogniser is never the way: a *multi-click* recogniser on a
|
||
/// dragless subtree — a sequential `.onTapGesture(count: 2)` or even a simultaneous two-tap —
|
||
/// makes macOS hold every primary click on that subtree pending disambiguation for the system
|
||
/// double-click interval. (A real `.onDrag` forces immediate delivery, which is why the card
|
||
/// faces, the lane header and the trash rows — `CardFaceView`'s simultaneous arrangement — can
|
||
/// carry one and stay instant. A lone count-1 tap needs no such help: measured on real events
|
||
/// 2026-08-07, `LaneView`'s dragless empty-space layer fires in ~1–3 ms — there is nothing to
|
||
/// disambiguate. And an `.onDrag` must never be added there *for* delivery: even an
|
||
/// empty-provider drag source claims drags outright and kills the rubber band's simultaneous
|
||
/// `DragGesture`.) A single `.onTapGesture` fires once per click of a run, so branching on this
|
||
/// count expresses "first click selects, second creates" — Finder's cadence — with exactly one
|
||
/// recogniser and nothing to disambiguate.
|
||
enum PointerClick {
|
||
|
||
/// The `clickCount` of the click being handled: 1 for a lone click or a run's first, 2 for
|
||
/// the second click of a double, and so on.
|
||
///
|
||
/// `NSApp.currentEvent` rather than a stored flag: the event being dispatched *is* the click,
|
||
/// and AppKit's `clickCount` already embodies the system double-click interval and the
|
||
/// spatial-proximity rule, so no timer here could disagree with the event stream's own
|
||
/// pairing. A current event that is not a mouse click (or is absent — a synthetic call) reads
|
||
/// as a first click, which fails toward the single-click action: selection stays reachable.
|
||
@MainActor
|
||
static var count: Int {
|
||
guard let event = NSApp.currentEvent else { return 1 }
|
||
switch event.type {
|
||
case .leftMouseDown, .leftMouseUp: return max(1, event.clickCount)
|
||
default: return 1
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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
|
||
|
||
/// Whether two of these lend the same band — the whole of what this value contributes to
|
||
/// `LaneView.==` and `CardFaceView.==` (`BoardDropContext.isEquivalent(to:)` is its twin).
|
||
///
|
||
/// All three members are window-lived objects, so identity is the comparison: this struct holds
|
||
/// no geometry and no closures of its own, and the strip rebuilds it on every body pass.
|
||
nonisolated func isEquivalent(to other: MarqueeControl) -> Bool {
|
||
session === other.session
|
||
&& registry === other.registry
|
||
&& store === other.store
|
||
}
|
||
|
||
/// 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 container is fixed at the origin** — 04-interactions.md ▸ The trash's rule ("the
|
||
/// rubber band stays on the side it started on"), 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(in container: ItemContainer) -> 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, in: container)
|
||
}
|
||
session.update(to: value.location)
|
||
guard let rect = session.rect else { return }
|
||
let ids = MarqueeMath.selection(rect: rect, targets: registry.all, in: session.container)
|
||
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, in: session.container, 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.
|
||
///
|
||
/// **This is also how the search filter reaches the band and the arrows** (04-interactions.md
|
||
/// § Search, "marquee, … arrow nav … all read it"): a card the filter hides is never built, so
|
||
/// it registers nothing, and the two surfaces that navigate by drawn frames narrow with the
|
||
/// masonry rather than re-running the predicate.
|
||
///
|
||
/// **A leaving card stays input-reachable for its out-transition** (04-interactions.md § Search,
|
||
/// settled): "marquee and arrow targets deregister when the ~0.28 s animate-out ends, so a card
|
||
/// mid-departure is briefly reachable while already out of the selection — accepted: it is
|
||
/// literally on screen for that span, and closing the window would teach three input sites a
|
||
/// predicate the layout already applied". That is this modifier's construction rather than a rule
|
||
/// it implements: `onDisappear` fires when SwiftUI really removes the view — at the end of the
|
||
/// card transition `Motion.contentReflow` is timing — not when the query changed, so the
|
||
/// registration outlives the filter by exactly the length of the animation and not a frame more.
|
||
/// The card has already left the selection by then (`TransientBoardState.constrainToSearch(in:)`
|
||
/// runs at the keystroke), which is what makes the window visible rather than phantom.
|
||
@MainActor
|
||
func marqueeTarget(
|
||
_ id: ItemID,
|
||
kind: SelectionKind,
|
||
container: ItemContainer,
|
||
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, container: container, frame: frame))
|
||
}
|
||
.onDisappear { registry.remove(id) }
|
||
}
|
||
}
|