Clicks land the instant they happen — the empty-space gestures move behind the masonry

The measured defect: LaneView's empty-space double-click was a second
sequential .onTapGesture(count: 2) stacked over the single tap, and that
recogniser held every click in the lane — its own empty space and every
card face alike — hostage to the system double-click interval while it
disambiguated (~475 ms click-to-selection on a hosted board). The fix is
structural: the empty-space surfaces live on a background layer behind the
masonry, so a card's click never shares a gesture path with a lane
recogniser; one .onTapGesture branches on PointerClick.count (AppKit's own
clickCount, read the way ClickModifier reads the keyboard) — first click
selects, second creates, Finder's cadence with nothing to disambiguate; and
the layer carries a load-bearing empty .onDrag, because without a drag
source macOS holds primary clicks pending multi-click disambiguation
(measured: never fires alone, ~90 ms with one present). A measured viewport
floor makes each lane's blank space actually belong to the layer — a
ScrollView proposes nothing along its scroll axis, so only an explicit
minimum stretches the content — with the trash column as its twin, less the
padding that sits inside its scroll content. The template chooser's stacked
pair collapses to the same one-recogniser branch, and the attachment rows
move their double-click to a simultaneous gesture (instant there, because
the row's real .onDrag forces immediate delivery). PointerLatencyTests pins
the recovery with synthetic pointer events on a hosted board.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-07 11:22:16 -04:00
parent cfee4a4b41
commit eef0a4539f
7 changed files with 602 additions and 39 deletions
+94 -20
View File
@@ -122,6 +122,11 @@ struct LaneView: View, Equatable {
/// is set to.
@State private var measuredHeaderHeight: CGFloat = 0
/// The card stack's viewport height the masonry's height *floor* (`scrollableCards`).
/// Measured because a `ScrollView` proposes nothing along its scroll axis, so no frame maximum
/// can stretch the content to fill it; only an explicit minimum can.
@State private var scrollViewportHeight: CGFloat = 0
/// This lane's edge-autoscroll driver one per lane, ticking only while a card session is in
/// flight (`DragAutoScroller`, DRAG-REORDER.md § Edge autoscroll).
@State private var autoScroller = DragAutoScroller()
@@ -790,7 +795,14 @@ struct LaneView: View, Equatable {
.id(slot.id)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
// **The measured viewport is the masonry's height floor.** A `ScrollView` proposes
// nothing along its scroll axis, so `maxHeight: .infinity` cannot stretch this view
// sized to fit its cards, it left the blank space beneath them (the whole body, in an
// empty lane) outside the content shape below, and every surface attached to it dead
// there: the lane-select tap, the double-click create, the context menu, and the band.
// The explicit minimum is what makes "click-drag rubber-bands across lanes" arm from a
// lane's own empty space (04-interactions.md § Selection; `TrashLaneView` is the twin).
.frame(maxWidth: .infinity, minHeight: scrollViewportHeight, maxHeight: .infinity, alignment: .topLeading)
// The drag's reflow-to-make-room inside the lane, keyed on **this lane's shadow run**
// and nothing broader (03-board-ui.md § Motion). `MasonryLayout` is a `Layout` over one
// `ForEach` precisely so the round-robin reshuffle animates as positional slides rather
@@ -806,34 +818,92 @@ struct LaneView: View, Equatable {
)
}
.onDisappear { drops.registry.removeGrid(lane.id) }
// **The empty-space click surfaces live on a background layer, not on the container**
// (the 2026-08-06 click-latency fix). The pathfinder shape `.onTapGesture(count: 2)`
// stacked over `.onTapGesture` on the container that wraps the masonry made SwiftUI
// hold *every* single click in the lane, its own empty space and every card face
// alike, hostage to the system double-click interval while the sequential two-tap
// recogniser disambiguated (measured ~475 ms click-to-selection on a hosted board;
// `PointerLatencyTests` pins the recovery).
//
// The background layer is the structural answer to both halves of that defect. A card
// click hit-tests to the card, and the card is **not** a descendant of this layer, so
// no recogniser here can ever hold a card's click where a container gesture always
// shares the card's gesture path, an ancestor's simultaneity notwithstanding. And a
// click that does land here *is* empty space by construction the double-click create
// needs no geometric guard against the cards, the way the rubber band's begin does
// (`MarqueeControl`), because the masonry above already consumed everything that was
// not empty.
.background {
Rectangle()
.fill(.clear)
.contentShape(Rectangle())
// **The empty provider is load-bearing, and it is not a drag** (measured,
// 2026-08-06): without a drag source on this layer, macOS holds its primary
// clicks pending multi-click disambiguation a lone click on lane empty
// space simply never fired its tap on the hosted board, drag source absent,
// and fired in ~90 ms with one present. The card faces, the lane header and
// the trash rows are instant for exactly this reason: their real `.onDrag`
// forces immediate event delivery for the whole subtree. An **empty**
// provider keeps that delivery guarantee while refusing every actual drag
// before a session starts (`CardAttachmentsSection`'s gone-file idiom), so
// dragging from empty space still belongs wholly to the rubber band's
// simultaneous `DragGesture` on the container whose begin guard already
// expects to sample drags it must decline (`MarqueeControl`).
.onDrag { NSItemProvider() }
// **One recogniser, both meanings** a single `.onTapGesture` that branches
// on `PointerClick.count`, AppKit's own `mouseDown` idiom. Not a second
// two-tap recogniser in *either* form: sequential stacking is the bug this
// fix removes, and even a simultaneous `TapGesture(count: 2)` makes macOS
// hold this layer's primary clicks for the whole double-click interval,
// because the layer unlike the card faces, the header and the trash rows
// carries no `.onDrag` to force immediate delivery (see `PointerClick`).
// A lone tap fires once; a double fires it once per click, so the branch is
// Finder's cadence exactly: the first click selects, the second creates.
//
// "Single click selects the lane (click again to unselect)" the toggle the
// header shares (04-interactions.md § Selection), with the modifier grammar
// on top. "Double click creates a card at the bottom" **plain only**: and
// double-clicks are selection gestures that happened twice, the card face's
// settled reading, so their second click re-enters the grammar instead of
// creating.
.onTapGesture {
let modifier = ClickModifier.current
if modifier == .plain, PointerClick.count > 1 {
// The second click of a plain double: the create. Never also the
// toggle it would unselect the lane the first click just selected,
// under the placeholder this opens. A third click of a triple lands
// here too and no-ops on `isEditingInline`: the placeholder is open.
guard !store.isReadOnly, !store.isEditingInline else { return }
store.transient.beginPlaceholder(inLane: lane.id)
return
}
store.click(
SelectionTarget(id: lane.id, kind: .lane, container: .board),
modifier: modifier,
togglesOnRepeat: true
)
}
}
// The edge-autoscroll anchor, **inside** the scroll view's content so
// `enclosingScrollView` resolves (`DragAutoScrollAnchor`). Every scroll step re-resolves
// the proposal through the same shared retarget the drop delegate uses, because the
// cursor is stationary while the content moves under it.
//
// **Behind the click layer above** (a later `.background` stacks further back): the
// anchor is a plain `NSView`, hit-testable by default, and in front of the click layer
// it would swallow every empty-space click before the layer's recognisers saw one. It
// needs no hits itself it exists to sit in the hierarchy and resolve its enclosing
// scroll view.
.background {
DragAutoScrollAnchor(scroller: autoScroller) {
drops.retargetCards(inLane: lane.id)
}
}
.contentShape(Rectangle())
// Order matters: the two-tap recogniser must be attached first so a double click is not
// consumed as two singles.
.onTapGesture(count: 2) {
guard !store.isReadOnly, !store.isEditingInline else { return }
store.transient.beginPlaceholder(inLane: lane.id)
}
// "Single click selects the lane (click again to unselect)" the toggle the header
// shares (04-interactions.md § Selection), and the modifier grammar on top of it.
.onTapGesture {
store.click(
SelectionTarget(id: lane.id, kind: .lane, container: .board),
modifier: .current,
togglesOnRepeat: true
)
}
// The rubber band's first surface "click-drag rubber-bands across lanes". Simultaneous
// so the taps above stay instant; the band's own begin guard is what keeps a drag that
// started on a card face out of it (`MarqueeControl`).
// The rubber band's first surface "click-drag rubber-bands across lanes". On the
// container, not the layer above: ancestry means a drag over cards and empty space
// alike reaches it, and the band's own begin guard is what keeps a drag that started
// on a card face out of it (`MarqueeControl`). Simultaneous, so the taps stay instant.
.simultaneousGesture(marquee.gesture(in: .board))
// The same menu the header carries "one menu, invoked on the header or lane empty
// space alike" (03-board-ui.md § Lane, settled).
@@ -852,6 +922,10 @@ struct LaneView: View, Equatable {
autoScroller.bodyPointSize = pointSize
await autoScroller.run()
}
// The floor's measurement the scroll view's own height, which is exactly the space the
// masonry must cover for the empty-surface gestures above. No feedback loop: the lane's
// height is the strip's to give, so the content growing to the floor never moves the floor.
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { scrollViewportHeight = $0 }
}
/// Where a card drag would land **in this lane**, or `nil` when the proposal is elsewhere.
+36
View File
@@ -24,6 +24,42 @@ extension ClickModifier {
}
}
// 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 sequential
/// `.onTapGesture(count: 2)` makes every single click on its subtree wait out the system
/// double-click interval and on macOS even a *simultaneous* two-tap recogniser holds primary
/// clicks on views that carry no `.onDrag`. (A drag source forces immediate event delivery, which
/// is why the card faces, the lane header and the trash rows `CardFaceView`'s simultaneous
/// arrangement stay instant; `LaneView`'s empty-space layer measurably does not.) 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
+25 -8
View File
@@ -129,6 +129,11 @@ struct TrashLaneView: View {
/// Between the cards `LaneView.cardSpacing`, because these are the same cards.
private var cardSpacing: CGFloat { BoardMetrics.cardSpacing(bodyPointSize: pointSize) }
/// The column's scroll viewport height the rows' height *floor* (`scrollableCards`), measured
/// for `LaneView.scrollViewportHeight`'s reason: a `ScrollView` proposes nothing along its
/// scroll axis, so only an explicit minimum can stretch the content to fill it.
@State private var scrollViewportHeight: CGFloat = 0
var body: some View {
// The strip's third body level, observed (`BoardRenderMetrics`) DEBUG only, and a
// `let _` because `body` is a `@ViewBuilder` and a bare `Void` call is not a view.
@@ -388,14 +393,22 @@ struct TrashLaneView: View {
// nothing else** (03-board-ui.md § Motion's narrow keys) the trash column's own copy of
// the rule `BoardView` applies to the strip and `LaneView` to its masonry.
.animation(Motion.dragReflow(reduced: reduceMotion), value: proposal)
// `maxHeight: .infinity` here, not just `maxWidth`, is what makes the gesture surface
// below reach the column's full height rather than stopping where the last card ends
// the same fix `LaneView.scrollableCards` applies to its masonry, and for the identical
// reason: a `ScrollView` proposes its content only the height that content asks for, so a
// view sized to fit its cards leaves the blank space beneath them un-hit-testable. "The
// column's gesture surface is full height" (04-interactions.md The trash, settled)
// needs that blank space to actually belong to the view the gesture below is on.
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
// **The measured viewport is the rows' height floor** this is what makes the gesture
// surface below reach the column's full height rather than stopping where the last card
// ends. `maxHeight: .infinity` alone could not: a `ScrollView` proposes *nothing* along
// its scroll axis, so no frame maximum stretches the content, and a view sized to fit
// its cards leaves the blank space beneath them un-hit-testable. "The column's gesture
// surface is full height" (04-interactions.md The trash, settled) needs that blank
// space to actually belong to the view the gesture below is on, so the measured floor
// supplies it less the plate padding, which sits inside the scroll content here and
// would otherwise make an empty column scrollable by its own inset
// (`LaneView.scrollableCards` is the twin, with its padding outside).
.frame(
maxWidth: .infinity,
minHeight: max(0, scrollViewportHeight - 2 * BoardMetrics.lanePlatePadding(bodyPointSize: pointSize)),
maxHeight: .infinity,
alignment: .topLeading
)
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
.contentShape(Rectangle())
// The band's trash-side surface. It arms from the column's empty space, full height
@@ -405,6 +418,10 @@ struct TrashLaneView: View {
// gesture priority (`MarqueeControl`).
.simultaneousGesture(marquee.gesture(in: .trash))
}
// The floor's measurement `LaneView`'s, on the trash side: the scroll view's own height
// is the space the rows must cover for the empty-surface gesture above, and the content
// growing to the floor never moves the floor.
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { scrollViewportHeight = $0 }
}
}
+13 -7
View File
@@ -166,17 +166,23 @@ struct CardAttachmentsSection: View {
)
.contentShape(Rectangle())
// Double-click opens, a single click selects (05 Attachments; the window's click grammar,
// where clicking selects and never edits). The two-count gesture is declared first so
// SwiftUI gives it the chance to claim the second click.
.onTapGesture(count: 2) {
isFocused = true
attachments.selected = name
attachments.open(name)
}
// where clicking selects and never edits). The two-count gesture rides `simultaneousGesture`
// rather than stacking as a second `.onTapGesture` a sequential pair makes the single
// click wait out the double-click interval before selecting (`CardFaceView`'s arrangement;
// the 2026-08-06 latency fix). Simultaneity stays instant *here* because the row carries
// `.onDrag` below, which forces immediate click delivery a surface without a drag source
// must branch one recogniser on `PointerClick.count` instead (`LaneView`'s empty space).
// The first click of a pair selects, the second opens; the open re-asserting focus and
// selection is idempotent.
.onTapGesture {
isFocused = true
attachments.selected = name
}
.simultaneousGesture(TapGesture(count: 2).onEnded {
isFocused = true
attachments.selected = name
attachments.open(name)
})
// **Rows drag out their file URL** (05 Attachments; 11-command-nexus.md Pointer-only
// affordances) which is what makes drag-to-Finder and drag-into-another-app work with no
// export path of this app's own. An empty provider for a row whose file has gone refuses the