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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user