Files
lanework/Kanban/UI/Board/SelectionClicks.swift
T
rzen 8aefaf23ce The empty provider was never load-bearing — the dragless layer frees the rubber band
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
2026-08-07 12:55:54 -04:00

179 lines
9.7 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ~13 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) }
}
}