From 8aefaf23ce66cdabc15c4e8999ca3c317c758c1f Mon Sep 17 00:00:00 2001 From: rzen Date: Fri, 7 Aug 2026 12:55:54 -0400 Subject: [PATCH] =?UTF-8?q?The=20empty=20provider=20was=20never=20load-bea?= =?UTF-8?q?ring=20=E2=80=94=20the=20dragless=20layer=20frees=20the=20rubbe?= =?UTF-8?q?r=20band?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Kanban/App/BoardWindowHost.swift | 2 +- Kanban/UI/Board/BoardCommands.swift | 2 +- Kanban/UI/Board/BoardView.swift | 2 +- Kanban/UI/Board/CardFaceView.swift | 7 +++- Kanban/UI/Board/LaneView.swift | 38 +++++++++---------- Kanban/UI/Board/SelectionClicks.swift | 21 +++++----- KanbanTests/BoardRenderPerformanceTests.swift | 2 +- KanbanTests/PointerLatencyTests.swift | 2 +- KanbanTests/ViewEquatableTests.swift | 2 +- 9 files changed, 42 insertions(+), 36 deletions(-) diff --git a/Kanban/App/BoardWindowHost.swift b/Kanban/App/BoardWindowHost.swift index 960e807..a0fe7e2 100644 --- a/Kanban/App/BoardWindowHost.swift +++ b/Kanban/App/BoardWindowHost.swift @@ -260,7 +260,7 @@ struct BoardWindowHost: View { /// 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 /// (02-architecture.md § Windows). - private var openCard: (ItemID) -> Void { + private var openCard: @MainActor (ItemID) -> Void { { cardID in openWindow(id: WindowID.card, value: CardWindowRef(board: ref, cardID: cardID)) } diff --git a/Kanban/UI/Board/BoardCommands.swift b/Kanban/UI/Board/BoardCommands.swift index b8b4ead..25f76a2 100644 --- a/Kanban/UI/Board/BoardCommands.swift +++ b/Kanban/UI/Board/BoardCommands.swift @@ -41,7 +41,7 @@ final class CardOpener { /// `nil` until the window's board has loaded, which is also exactly when Open Card has nothing /// to act on. - var open: ((ItemID) -> Void)? + var open: (@MainActor (ItemID) -> Void)? init() {} } diff --git a/Kanban/UI/Board/BoardView.swift b/Kanban/UI/Board/BoardView.swift index 8940e18..921610b 100644 --- a/Kanban/UI/Board/BoardView.swift +++ b/Kanban/UI/Board/BoardView.swift @@ -67,7 +67,7 @@ struct BoardView: View { /// 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` /// 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 /// fill in `focusBoard` — Escape's "in an empty field it returns focus to the board" needs the diff --git a/Kanban/UI/Board/CardFaceView.swift b/Kanban/UI/Board/CardFaceView.swift index 3eebd9f..0521027 100644 --- a/Kanban/UI/Board/CardFaceView.swift +++ b/Kanban/UI/Board/CardFaceView.swift @@ -30,11 +30,14 @@ import SwiftUI /// **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 /// 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 /// (04-interactions.md ▸ Selection). - case board(openCard: (ItemID) -> Void) + case board(openCard: @MainActor (ItemID) -> Void) /// A card in `/.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). diff --git a/Kanban/UI/Board/LaneView.swift b/Kanban/UI/Board/LaneView.swift index d5933ba..580b22b 100644 --- a/Kanban/UI/Board/LaneView.swift +++ b/Kanban/UI/Board/LaneView.swift @@ -71,7 +71,7 @@ struct LaneView: View, Equatable { /// 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 /// 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 /// and handed to `Motion`, which owns what "reduced" means. @@ -844,26 +844,26 @@ struct LaneView: View, Equatable { 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() } + // **No drag source on this layer, deliberately** (measured on real events, + // 2026-08-07): an `.onDrag` here — even one whose provider is empty — claims + // the mouse-drag the moment the cursor crosses the drag threshold, and the + // rubber band's simultaneous `DragGesture` on the container gets one sample + // and then silence: the band begins and freezes, and even the mouseUp never + // reaches the app. Dragless, the same sweep tracks every sample. And the drag + // source buys nothing in return: a bare count-1 tap on this layer fires in + // ~1–3 ms in a real event stream, identical to the with-`.onDrag` shape — the + // hold that made an empty provider look load-bearing (2026-08-06, "a lone + // click never fired") was the hosted harness's sterile `NSApp.postEvent` + // stream, which over-holds; real streams have nothing to disambiguate here. + // So: drags from empty space belong wholly to the band (`MarqueeControl`), + // whose begin guard keeps card-face drags out by geometry. // **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`). + // fix removes, and a *multi-click* recogniser — sequential or simultaneous — + // is what makes macOS hold a dragless subtree's primary clicks for the whole + // double-click interval (see `PointerClick`; the count-1 tap alone triggers + // no such hold). // 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. // @@ -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. 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`). @Environment(\.colorSchemeContrast) private var contrast diff --git a/Kanban/UI/Board/SelectionClicks.swift b/Kanban/UI/Board/SelectionClicks.swift index 87bfd07..b022f31 100644 --- a/Kanban/UI/Board/SelectionClicks.swift +++ b/Kanban/UI/Board/SelectionClicks.swift @@ -31,15 +31,18 @@ extension ClickModifier { /// 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. +/// 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 diff --git a/KanbanTests/BoardRenderPerformanceTests.swift b/KanbanTests/BoardRenderPerformanceTests.swift index d183482..e3d9198 100644 --- a/KanbanTests/BoardRenderPerformanceTests.swift +++ b/KanbanTests/BoardRenderPerformanceTests.swift @@ -100,7 +100,7 @@ private struct ZoomedBoard: View { let store: BoardStore let window: @MainActor () -> NSWindow? let confirmations: TrashConfirmations - let openCard: (ItemID) -> Void + let openCard: @MainActor (ItemID) -> Void let search: BoardSearchPresentation @Environment(AppModel.self) private var appModel diff --git a/KanbanTests/PointerLatencyTests.swift b/KanbanTests/PointerLatencyTests.swift index ed70975..b03aaf2 100644 --- a/KanbanTests/PointerLatencyTests.swift +++ b/KanbanTests/PointerLatencyTests.swift @@ -71,7 +71,7 @@ private struct ZoomedBoard: View { let store: BoardStore let window: @MainActor () -> NSWindow? let confirmations: TrashConfirmations - let openCard: (ItemID) -> Void + let openCard: @MainActor (ItemID) -> Void let search: BoardSearchPresentation @Environment(AppModel.self) private var appModel diff --git a/KanbanTests/ViewEquatableTests.swift b/KanbanTests/ViewEquatableTests.swift index 7da10f2..9a891b2 100644 --- a/KanbanTests/ViewEquatableTests.swift +++ b/KanbanTests/ViewEquatableTests.swift @@ -69,7 +69,7 @@ private func makeLane( slotWidth: CGFloat = 260, drops: BoardDropContext, marquee: MarqueeControl, - openCard: @escaping (ItemID) -> Void = { _ in } + openCard: @escaping @MainActor (ItemID) -> Void = { _ in } ) -> LaneView { LaneView( store: store,