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
This commit is contained in:
@@ -260,7 +260,7 @@ struct BoardWindowHost: View {
|
|||||||
/// Opens a card's window. `openWindow(value:)` with a ref that already has a window focuses it,
|
/// Opens a card's window. `openWindow(value:)` with a ref that already has a window focuses it,
|
||||||
/// so "at most one card window per card (reopen focuses)" needs no bookkeeping here
|
/// so "at most one card window per card (reopen focuses)" needs no bookkeeping here
|
||||||
/// (02-architecture.md § Windows).
|
/// (02-architecture.md § Windows).
|
||||||
private var openCard: (ItemID) -> Void {
|
private var openCard: @MainActor (ItemID) -> Void {
|
||||||
{ cardID in
|
{ cardID in
|
||||||
openWindow(id: WindowID.card, value: CardWindowRef(board: ref, cardID: cardID))
|
openWindow(id: WindowID.card, value: CardWindowRef(board: ref, cardID: cardID))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ final class CardOpener {
|
|||||||
|
|
||||||
/// `nil` until the window's board has loaded, which is also exactly when Open Card has nothing
|
/// `nil` until the window's board has loaded, which is also exactly when Open Card has nothing
|
||||||
/// to act on.
|
/// to act on.
|
||||||
var open: ((ItemID) -> Void)?
|
var open: (@MainActor (ItemID) -> Void)?
|
||||||
|
|
||||||
init() {}
|
init() {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ struct BoardView: View {
|
|||||||
/// Opens a card's window — ⌘↩'s second half (04-interactions.md ▸ Grammar). A closure from
|
/// Opens a card's window — ⌘↩'s second half (04-interactions.md ▸ Grammar). A closure from
|
||||||
/// `BoardWindowHost` rather than an `openWindow` call here, because building a `CardWindowRef`
|
/// `BoardWindowHost` rather than an `openWindow` call here, because building a `CardWindowRef`
|
||||||
/// needs the board's own window ref, which is the host's identity and not the board's.
|
/// needs the board's own window ref, which is the host's identity and not the board's.
|
||||||
let openCard: (ItemID) -> Void
|
let openCard: @MainActor (ItemID) -> Void
|
||||||
|
|
||||||
/// The toolbar search field's handle (`BoardSearchPresentation`), threaded down so the strip can
|
/// The toolbar search field's handle (`BoardSearchPresentation`), threaded down so the strip can
|
||||||
/// fill in `focusBoard` — Escape's "in an empty field it returns focus to the board" needs the
|
/// fill in `focusBoard` — Escape's "in an empty field it returns focus to the board" needs the
|
||||||
|
|||||||
@@ -30,11 +30,14 @@ import SwiftUI
|
|||||||
/// **Only the trash side carries the confirmation host** (settled): the board side's Delete is the
|
/// **Only the trash side carries the confirmation host** (settled): the board side's Delete is the
|
||||||
/// ordinary staged move into `.trash/` and never stands an alert, so `board` needs nothing beyond the
|
/// ordinary staged move into `.trash/` and never stands an alert, so `board` needs nothing beyond the
|
||||||
/// card opener.
|
/// card opener.
|
||||||
enum CardFaceRole {
|
/// `Sendable` because `CardFaceView.==` is nonisolated and a nonisolated context may only read a
|
||||||
|
/// main-actor `let` of Sendable type — which is why `openCard` is typed `@MainActor` (an isolated
|
||||||
|
/// function type is Sendable; a bare one is not, and would sink the whole enum).
|
||||||
|
enum CardFaceRole: Sendable {
|
||||||
|
|
||||||
/// A card in a lane. Carries the board window's card opener — ⌘↩'s pointer twin
|
/// A card in a lane. Carries the board window's card opener — ⌘↩'s pointer twin
|
||||||
/// (04-interactions.md ▸ Selection).
|
/// (04-interactions.md ▸ Selection).
|
||||||
case board(openCard: (ItemID) -> Void)
|
case board(openCard: @MainActor (ItemID) -> Void)
|
||||||
|
|
||||||
/// A card in `<root>/.trash/`. Carries the window's purge-alert host, because the trash's Delete
|
/// A card in `<root>/.trash/`. Carries the window's purge-alert host, because the trash's Delete
|
||||||
/// is the permanent one and "confirms exactly where the loss is real" (03 § Trash).
|
/// is the permanent one and "confirms exactly where the loss is real" (03 § Trash).
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ struct LaneView: View, Equatable {
|
|||||||
/// Opens a card's window — ⌘↩'s second half (04-interactions.md ▸ Grammar, "commits and opens
|
/// Opens a card's window — ⌘↩'s second half (04-interactions.md ▸ Grammar, "commits and opens
|
||||||
/// the card window"). Supplied by the strip, which is supplied by the host: a lane has no
|
/// the card window"). Supplied by the strip, which is supplied by the host: a lane has no
|
||||||
/// business knowing about `WindowGroup` keys.
|
/// business knowing about `WindowGroup` keys.
|
||||||
let openCard: (ItemID) -> Void
|
let openCard: @MainActor (ItemID) -> Void
|
||||||
|
|
||||||
/// Reduce Motion, for the card transition below (10-accessibility.md). Read from the environment
|
/// Reduce Motion, for the card transition below (10-accessibility.md). Read from the environment
|
||||||
/// and handed to `Motion`, which owns what "reduced" means.
|
/// and handed to `Motion`, which owns what "reduced" means.
|
||||||
@@ -844,26 +844,26 @@ struct LaneView: View, Equatable {
|
|||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(.clear)
|
.fill(.clear)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
// **The empty provider is load-bearing, and it is not a drag** (measured,
|
// **No drag source on this layer, deliberately** (measured on real events,
|
||||||
// 2026-08-06): without a drag source on this layer, macOS holds its primary
|
// 2026-08-07): an `.onDrag` here — even one whose provider is empty — claims
|
||||||
// clicks pending multi-click disambiguation — a lone click on lane empty
|
// the mouse-drag the moment the cursor crosses the drag threshold, and the
|
||||||
// space simply never fired its tap on the hosted board, drag source absent,
|
// rubber band's simultaneous `DragGesture` on the container gets one sample
|
||||||
// and fired in ~90 ms with one present. The card faces, the lane header and
|
// and then silence: the band begins and freezes, and even the mouseUp never
|
||||||
// the trash rows are instant for exactly this reason: their real `.onDrag`
|
// reaches the app. Dragless, the same sweep tracks every sample. And the drag
|
||||||
// forces immediate event delivery for the whole subtree. An **empty**
|
// source buys nothing in return: a bare count-1 tap on this layer fires in
|
||||||
// provider keeps that delivery guarantee while refusing every actual drag
|
// ~1–3 ms in a real event stream, identical to the with-`.onDrag` shape — the
|
||||||
// before a session starts (`CardAttachmentsSection`'s gone-file idiom), so
|
// hold that made an empty provider look load-bearing (2026-08-06, "a lone
|
||||||
// dragging from empty space still belongs wholly to the rubber band's
|
// click never fired") was the hosted harness's sterile `NSApp.postEvent`
|
||||||
// simultaneous `DragGesture` on the container — whose begin guard already
|
// stream, which over-holds; real streams have nothing to disambiguate here.
|
||||||
// expects to sample drags it must decline (`MarqueeControl`).
|
// So: drags from empty space belong wholly to the band (`MarqueeControl`),
|
||||||
.onDrag { NSItemProvider() }
|
// whose begin guard keeps card-face drags out by geometry.
|
||||||
// **One recogniser, both meanings** — a single `.onTapGesture` that branches
|
// **One recogniser, both meanings** — a single `.onTapGesture` that branches
|
||||||
// on `PointerClick.count`, AppKit's own `mouseDown` idiom. Not a second
|
// on `PointerClick.count`, AppKit's own `mouseDown` idiom. Not a second
|
||||||
// two-tap recogniser in *either* form: sequential stacking is the bug this
|
// two-tap recogniser in *either* form: sequential stacking is the bug this
|
||||||
// fix removes, and even a simultaneous `TapGesture(count: 2)` makes macOS
|
// fix removes, and a *multi-click* recogniser — sequential or simultaneous —
|
||||||
// hold this layer's primary clicks for the whole double-click interval,
|
// is what makes macOS hold a dragless subtree's primary clicks for the whole
|
||||||
// because the layer — unlike the card faces, the header and the trash rows —
|
// double-click interval (see `PointerClick`; the count-1 tap alone triggers
|
||||||
// carries no `.onDrag` to force immediate delivery (see `PointerClick`).
|
// no such hold).
|
||||||
// A lone tap fires once; a double fires it once per click, so the branch is
|
// 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.
|
// Finder's cadence exactly: the first click selects, the second creates.
|
||||||
//
|
//
|
||||||
@@ -1256,7 +1256,7 @@ private struct NewCardStubView: View {
|
|||||||
/// the face this view draws and the identity the slot is keyed by are the same answer.
|
/// the face this view draws and the identity the slot is keyed by are the same answer.
|
||||||
let phase: NewCardPlaceholder.Phase
|
let phase: NewCardPlaceholder.Phase
|
||||||
|
|
||||||
let openCard: (ItemID) -> Void
|
let openCard: @MainActor (ItemID) -> Void
|
||||||
|
|
||||||
/// Increase Contrast, for the editor well's stroke below (10-accessibility.md; `Accommodations`).
|
/// Increase Contrast, for the editor well's stroke below (10-accessibility.md; `Accommodations`).
|
||||||
@Environment(\.colorSchemeContrast) private var contrast
|
@Environment(\.colorSchemeContrast) private var contrast
|
||||||
|
|||||||
@@ -31,15 +31,18 @@ extension ClickModifier {
|
|||||||
/// SwiftUI's `TapGesture` hands its handler nothing about the event.
|
/// 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
|
/// **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
|
/// click-latency fix). A second tap recogniser is never the way: a *multi-click* recogniser on a
|
||||||
/// `.onTapGesture(count: 2)` makes every single click on its subtree wait out the system
|
/// dragless subtree — a sequential `.onTapGesture(count: 2)` or even a simultaneous two-tap —
|
||||||
/// double-click interval — and on macOS even a *simultaneous* two-tap recogniser holds primary
|
/// makes macOS hold every primary click on that subtree pending disambiguation for the system
|
||||||
/// clicks on views that carry no `.onDrag`. (A drag source forces immediate event delivery, which
|
/// double-click interval. (A real `.onDrag` forces immediate delivery, which is why the card
|
||||||
/// is why the card faces, the lane header and the trash rows — `CardFaceView`'s simultaneous
|
/// faces, the lane header and the trash rows — `CardFaceView`'s simultaneous arrangement — can
|
||||||
/// arrangement — stay instant; `LaneView`'s empty-space layer measurably does not.) A single
|
/// carry one and stay instant. A lone count-1 tap needs no such help: measured on real events
|
||||||
/// `.onTapGesture` fires once per click of a run, so branching on this count expresses
|
/// 2026-08-07, `LaneView`'s dragless empty-space layer fires in ~1–3 ms — there is nothing to
|
||||||
/// "first click selects, second creates" — Finder's cadence — with exactly one recogniser and
|
/// disambiguate. And an `.onDrag` must never be added there *for* delivery: even an
|
||||||
/// nothing to disambiguate.
|
/// 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 {
|
enum PointerClick {
|
||||||
|
|
||||||
/// The `clickCount` of the click being handled: 1 for a lone click or a run's first, 2 for
|
/// The `clickCount` of the click being handled: 1 for a lone click or a run's first, 2 for
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ private struct ZoomedBoard: View {
|
|||||||
let store: BoardStore
|
let store: BoardStore
|
||||||
let window: @MainActor () -> NSWindow?
|
let window: @MainActor () -> NSWindow?
|
||||||
let confirmations: TrashConfirmations
|
let confirmations: TrashConfirmations
|
||||||
let openCard: (ItemID) -> Void
|
let openCard: @MainActor (ItemID) -> Void
|
||||||
let search: BoardSearchPresentation
|
let search: BoardSearchPresentation
|
||||||
|
|
||||||
@Environment(AppModel.self) private var appModel
|
@Environment(AppModel.self) private var appModel
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ private struct ZoomedBoard: View {
|
|||||||
let store: BoardStore
|
let store: BoardStore
|
||||||
let window: @MainActor () -> NSWindow?
|
let window: @MainActor () -> NSWindow?
|
||||||
let confirmations: TrashConfirmations
|
let confirmations: TrashConfirmations
|
||||||
let openCard: (ItemID) -> Void
|
let openCard: @MainActor (ItemID) -> Void
|
||||||
let search: BoardSearchPresentation
|
let search: BoardSearchPresentation
|
||||||
|
|
||||||
@Environment(AppModel.self) private var appModel
|
@Environment(AppModel.self) private var appModel
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ private func makeLane(
|
|||||||
slotWidth: CGFloat = 260,
|
slotWidth: CGFloat = 260,
|
||||||
drops: BoardDropContext,
|
drops: BoardDropContext,
|
||||||
marquee: MarqueeControl,
|
marquee: MarqueeControl,
|
||||||
openCard: @escaping (ItemID) -> Void = { _ in }
|
openCard: @escaping @MainActor (ItemID) -> Void = { _ in }
|
||||||
) -> LaneView {
|
) -> LaneView {
|
||||||
LaneView(
|
LaneView(
|
||||||
store: store,
|
store: store,
|
||||||
|
|||||||
Reference in New Issue
Block a user