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:
@@ -4,6 +4,8 @@ A board can now wear a background image, painted across the whole window with a
|
||||
|
||||
The background field is now written as a mapping — *{color: green}* instead of a bare *green* — and a board's may name an image beside the color.
|
||||
|
||||
Clicking a card or lane now selects it immediately, instead of pausing for about half a second.
|
||||
|
||||
**July 2026**
|
||||
|
||||
Version 2.0: Lanework's first release — a kanban app whose boards are ordinary folders of Markdown files on your Mac.
|
||||
|
||||
@@ -183,10 +183,16 @@ struct TemplateChooserView: View {
|
||||
) {
|
||||
ForEach(rows) { row in
|
||||
TemplateCard(row: row, isSelected: row.id == selected?.id)
|
||||
.onTapGesture { selection = row.id }
|
||||
// The list convention welcome's recents use, for the same reason: a
|
||||
// double click is how a chooser is answered without reaching for a button.
|
||||
.onTapGesture(count: 2) { choose() }
|
||||
// A double click is how a chooser is answered without reaching for a
|
||||
// button (welcome's list convention). One recogniser branching on
|
||||
// `PointerClick.count`, never a second two-tap one — stacked, it delays
|
||||
// the single click by the whole double-click interval; simultaneous, it
|
||||
// still holds clicks on a view with no drag source (`PointerClick`). The
|
||||
// first click of the pair selects the tile, which is also what aims
|
||||
// `choose()` at the clicked row.
|
||||
.onTapGesture {
|
||||
if PointerClick.count > 1 { choose() } else { selection = row.id }
|
||||
}
|
||||
// **Tab-reachable, and a button to the accessibility tree** — the tile is
|
||||
// the chooser's one act of choosing, so it has to be a control rather than a
|
||||
// decorated rectangle that happens to answer clicks (10-accessibility.md ▸
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// **Regression pins for the 2026-08-06 click-latency fix**, measured on a real hosted `BoardView`
|
||||
/// with synthetic pointer events.
|
||||
///
|
||||
/// The defect: `LaneView`'s empty-space double-click was a second `.onTapGesture(count: 2)` stacked
|
||||
/// over the single tap, and a sequential two-tap recogniser holds every single click on that
|
||||
/// container — its own empty space and every card face it wraps — hostage to the system
|
||||
/// double-click interval (500 ms here) while it disambiguates. Measured before the fix: ~475 ms
|
||||
/// from click to selection, and ~464 ms from right-click to menu when the right-click followed a
|
||||
/// left click. After: both a handful of milliseconds.
|
||||
///
|
||||
/// The fix moved the empty-space surfaces to a background layer behind the masonry (cards no
|
||||
/// longer share a gesture path with any lane recogniser), replaced the two-tap recogniser with one
|
||||
/// `.onTapGesture` branching on `PointerClick.count`, and gave the layer an empty-provider
|
||||
/// `.onDrag` — without a drag source, macOS holds a subtree's primary clicks pending multi-click
|
||||
/// disambiguation (the lone-click pin below is the tripwire for that regressing). The behavioral
|
||||
/// halves are pinned alongside the latency:
|
||||
/// - a card double-click opens the card window and creates **no** placeholder (the layer is not
|
||||
/// the card's ancestor, so its create can never fire for a card's clicks),
|
||||
/// - an empty-space double-click opens the placeholder **and keeps the lane selected** — the
|
||||
/// pair's second click is the create alone, never also the toggle (`PointerClick.count`).
|
||||
///
|
||||
/// Events go through `NSApp.postEvent` and are drained via `NSApp.nextEvent` rather than
|
||||
/// `window.sendEvent`, because `PointerClick` reads `NSApp.currentEvent` — which only the real
|
||||
/// dequeue path populates — and because the dequeue path is the one real clicks take through the
|
||||
/// hold-and-release machinery this suite exists to pin.
|
||||
|
||||
// MARK: - Fixture
|
||||
|
||||
private let laneCount = 6
|
||||
private let cardsPerLane = 30
|
||||
/// Lane 0 stays short so it has visible empty space to double-click.
|
||||
private let shortLaneCards = 3
|
||||
|
||||
private func laneName(_ lane: Int) -> String {
|
||||
String(format: "1%07d-1111-4111-8111-111111111111", lane)
|
||||
}
|
||||
|
||||
private func cardName(_ lane: Int, _ card: Int) -> String {
|
||||
String(format: "2%03d%04d-2222-4222-8222-222222222222", lane, card)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeFixture() throws -> WriterFixture {
|
||||
let fixture = try WriterFixture()
|
||||
try fixture.board(title: "Latency Board")
|
||||
for lane in 0..<laneCount {
|
||||
try fixture.lane(laneName(lane), order: "\((lane + 1) * 1024)", title: "Lane \(lane)")
|
||||
let cards = lane == 0 ? shortLaneCards : cardsPerLane
|
||||
for card in 0..<cards {
|
||||
try fixture.card(
|
||||
cardName(lane, card),
|
||||
in: laneName(lane),
|
||||
order: "\((card + 1) * 1024)",
|
||||
title: "Card \(lane)-\(card)",
|
||||
body: "Body text for card \(lane)-\(card)."
|
||||
)
|
||||
}
|
||||
}
|
||||
return fixture
|
||||
}
|
||||
|
||||
// MARK: - Hosting (BoardRenderPerformanceTests' harness, with an openCard capture)
|
||||
|
||||
private struct ZoomedBoard: View {
|
||||
let store: BoardStore
|
||||
let window: @MainActor () -> NSWindow?
|
||||
let confirmations: TrashConfirmations
|
||||
let openCard: (ItemID) -> Void
|
||||
let search: BoardSearchPresentation
|
||||
|
||||
@Environment(AppModel.self) private var appModel
|
||||
|
||||
var body: some View {
|
||||
BoardView(
|
||||
store: store,
|
||||
window: window,
|
||||
confirmations: confirmations,
|
||||
openCard: openCard,
|
||||
search: search
|
||||
)
|
||||
.environment(\.boardZoom, appModel.zoom.context)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class HostedBoard {
|
||||
let store: BoardStore
|
||||
let appModel: AppModel
|
||||
let window: NSWindow
|
||||
let view: NSView
|
||||
private(set) var openedCards: [ItemID] = []
|
||||
private let scratch: URL
|
||||
private let preferencesDomain: String
|
||||
|
||||
init(store: BoardStore, scratch: URL) {
|
||||
self.store = store
|
||||
self.scratch = scratch
|
||||
preferencesDomain = "dev.rzen.indie.Kanban.pointer-latency.\(UUID().uuidString)"
|
||||
appModel = AppModel(
|
||||
registryStorageURL: scratch.appendingPathComponent("board-registry.json"),
|
||||
clipboardStagingRoot: scratch.appendingPathComponent("Clipboard", isDirectory: true),
|
||||
preferences: UserDefaults(suiteName: preferencesDomain)!
|
||||
)
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 1600, height: 1000),
|
||||
styleMask: [.titled], backing: .buffered, defer: false
|
||||
)
|
||||
self.window = window
|
||||
var recordOpen: (ItemID) -> Void = { _ in }
|
||||
let root = ZoomedBoard(
|
||||
store: store,
|
||||
window: { [weak window] in window },
|
||||
confirmations: TrashConfirmations(),
|
||||
openCard: { recordOpen($0) },
|
||||
search: BoardSearchPresentation()
|
||||
)
|
||||
.environment(appModel)
|
||||
let hosting = NSHostingView(rootView: root)
|
||||
hosting.frame = NSRect(x: 0, y: 0, width: 1600, height: 1000)
|
||||
view = hosting
|
||||
window.contentView = hosting
|
||||
window.orderBack(nil)
|
||||
window.makeKey()
|
||||
recordOpen = { [weak self] id in
|
||||
MainActor.assumeIsolated { self?.openedCards.append(id) }
|
||||
}
|
||||
settle()
|
||||
}
|
||||
|
||||
deinit {
|
||||
window.orderOut(nil)
|
||||
window.contentView = nil
|
||||
try? FileManager.default.removeItem(at: scratch)
|
||||
UserDefaults.standard.removePersistentDomain(forName: preferencesDomain)
|
||||
}
|
||||
|
||||
func settle(turns: Int = 6) {
|
||||
for _ in 0..<turns {
|
||||
RunLoop.main.run(until: Date().addingTimeInterval(0.02))
|
||||
view.layoutSubtreeIfNeeded()
|
||||
window.displayIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func host(_ fixture: WriterFixture) throws -> HostedBoard {
|
||||
let scratch = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("PointerLatency-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: scratch, withIntermediateDirectories: true)
|
||||
return HostedBoard(store: try BoardStore(rootURL: fixture.root), scratch: scratch)
|
||||
}
|
||||
|
||||
// MARK: - Event synthesis
|
||||
|
||||
/// Posts one mouse event into the application's queue — the queue, not `window.sendEvent`, so the
|
||||
/// dequeue below stamps it as `NSApp.currentEvent` the way a real click is.
|
||||
@MainActor
|
||||
private func post(_ type: NSEvent.EventType, at p: NSPoint, in window: NSWindow, clicks: Int) {
|
||||
let event = NSEvent.mouseEvent(
|
||||
with: type, location: p, modifierFlags: [],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: window.windowNumber, context: nil,
|
||||
eventNumber: Int.random(in: 1...999_999), clickCount: clicks, pressure: 1
|
||||
)!
|
||||
NSApp.postEvent(event, atStart: false)
|
||||
}
|
||||
|
||||
/// Drains the queue through the real dequeue-and-dispatch path, then gives SwiftUI a turn.
|
||||
@MainActor
|
||||
private func pump(_ seconds: TimeInterval) {
|
||||
let deadline = Date().addingTimeInterval(seconds)
|
||||
repeat {
|
||||
while let event = NSApp.nextEvent(matching: .any, until: .distantPast, inMode: .default, dequeue: true) {
|
||||
NSApp.sendEvent(event)
|
||||
}
|
||||
RunLoop.main.run(until: min(Date().addingTimeInterval(0.004), deadline))
|
||||
} while Date() < deadline
|
||||
}
|
||||
|
||||
/// One full click — down then up, drained after each half.
|
||||
@MainActor
|
||||
private func click(at p: NSPoint, in window: NSWindow, clicks: Int = 1) {
|
||||
post(.leftMouseDown, at: p, in: window, clicks: clicks)
|
||||
pump(0.02)
|
||||
post(.leftMouseUp, at: p, in: window, clicks: clicks)
|
||||
pump(0.02)
|
||||
}
|
||||
|
||||
/// Pumps until `condition` holds; returns elapsed ms, or nil on timeout.
|
||||
@MainActor
|
||||
private func waitFor(_ timeout: TimeInterval, condition: () -> Bool) -> Double? {
|
||||
let t0 = CACurrentMediaTime()
|
||||
while CACurrentMediaTime() - t0 < timeout {
|
||||
pump(0.004)
|
||||
if condition() { return (CACurrentMediaTime() - t0) * 1000 }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// `waitFor`, with the pointer resting live near `p`: posts a `.mouseMoved` with 1 pt of jitter
|
||||
/// every ~30 ms, the micro-motion a real pointer always emits. AppKit's held-event machinery
|
||||
/// resolves pending click disambiguation off the *timestamps of subsequent events* — a perfectly
|
||||
/// sterile queue can defer a held click forever, which no real event stream ever does.
|
||||
@MainActor
|
||||
private func waitForWithMotion(
|
||||
at p: NSPoint, in window: NSWindow, timeout: TimeInterval, condition: () -> Bool
|
||||
) -> Double? {
|
||||
let t0 = CACurrentMediaTime()
|
||||
var lastMove = t0
|
||||
var jitter = false
|
||||
while CACurrentMediaTime() - t0 < timeout {
|
||||
pump(0.004)
|
||||
if condition() { return (CACurrentMediaTime() - t0) * 1000 }
|
||||
if CACurrentMediaTime() - lastMove > 0.03 {
|
||||
lastMove = CACurrentMediaTime()
|
||||
jitter.toggle()
|
||||
let moved = NSPoint(x: p.x + (jitter ? 1 : 0), y: p.y)
|
||||
let event = NSEvent.mouseEvent(
|
||||
with: .mouseMoved, location: moved, modifierFlags: [],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: window.windowNumber, context: nil,
|
||||
eventNumber: Int.random(in: 1...999_999), clickCount: 0, pressure: 0
|
||||
)!
|
||||
NSApp.postEvent(event, atStart: false)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// The menu-tracking observer's mailbox — statics because the notification closure is @Sendable.
|
||||
private enum MenuProbe {
|
||||
nonisolated(unsafe) static var beganAt: CFTimeInterval?
|
||||
}
|
||||
|
||||
// MARK: - Probe points
|
||||
|
||||
/// Lane 0's first card sits near the strip's top-leading corner; found empirically by the
|
||||
/// investigation probe and pinned by `#require` on the selection it produces.
|
||||
private let cardPoint = NSPoint(x: 90, y: 1000 - 90)
|
||||
|
||||
/// Finds a point whose click selects lane 0 itself — its empty space. Probed rather than
|
||||
/// hard-coded, so the pin does not depend on how far the lane's click surface happens to extend
|
||||
/// below its cards on any given layout.
|
||||
@MainActor
|
||||
private func findEmptySpacePoint(on board: HostedBoard) -> NSPoint? {
|
||||
let laneID = ItemID(rawValue: laneName(0))
|
||||
for x in stride(from: 60, through: 240, by: 60) {
|
||||
for yTop in stride(from: 500, through: 120, by: -60) {
|
||||
let p = NSPoint(x: CGFloat(x), y: 1000 - CGFloat(yTop))
|
||||
click(at: p, in: board.window, clicks: 1)
|
||||
_ = waitFor(0.4) { !board.store.selection.isEmpty }
|
||||
let hit = board.store.selection.ids == [laneID]
|
||||
board.store.clearSelection()
|
||||
pump(0.2)
|
||||
if hit { return p }
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - The pins
|
||||
|
||||
@MainActor
|
||||
@Suite("Pointer latency on a hosted board", .serialized)
|
||||
struct PointerLatencyTests {
|
||||
|
||||
@Test("A click on a card selects it without the double-click wait")
|
||||
func cardClickSelectsWithoutTheDoubleClickWait() throws {
|
||||
let fixture = try makeFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let board = try host(fixture)
|
||||
let store = board.store
|
||||
|
||||
post(.leftMouseDown, at: cardPoint, in: board.window, clicks: 1)
|
||||
pump(0.02)
|
||||
post(.leftMouseUp, at: cardPoint, in: board.window, clicks: 1)
|
||||
let latency = try #require(
|
||||
waitFor(2.0) { !store.selection.isEmpty },
|
||||
"the click never selected anything — did the layout move under the probe point?"
|
||||
)
|
||||
// Which card the point lands on is the masonry's business (column-major, two columns at
|
||||
// standard width) — what matters is that it is *a card*, instantly. Fixture card ids all
|
||||
// start with "2", lanes with "1".
|
||||
let selected = try #require(store.selection.ids.first)
|
||||
#expect(selected.rawValue.hasPrefix("2"),
|
||||
"the probe point should land on a card, selected \(store.selection.ids)")
|
||||
|
||||
// The defect measured ~475 ms here — the system double-click interval leaking into every
|
||||
// single click. The bound is generous headroom over the healthy ~5 ms, far under the
|
||||
// interval it must never re-approach.
|
||||
#expect(latency < 250, "click → selection took \(Int(latency)) ms")
|
||||
print(String(format: "── click → selection: %.0f ms", latency))
|
||||
}
|
||||
|
||||
@Test("A card double-click opens the card window and creates no placeholder")
|
||||
func cardDoubleClickOpensTheWindow() throws {
|
||||
let fixture = try makeFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let board = try host(fixture)
|
||||
let store = board.store
|
||||
|
||||
click(at: cardPoint, in: board.window, clicks: 1)
|
||||
_ = waitFor(1.0) { !store.selection.isEmpty }
|
||||
let target = try #require(store.selection.ids.first,
|
||||
"the pair's first click should select the card under the point")
|
||||
click(at: cardPoint, in: board.window, clicks: 2)
|
||||
_ = waitFor(1.0) { !board.openedCards.isEmpty }
|
||||
board.settle(turns: 2)
|
||||
|
||||
#expect(board.openedCards == [target],
|
||||
"the double-click should open exactly the clicked card, opened \(board.openedCards)")
|
||||
// The empty-space layer is a background sibling of the masonry, not the card's ancestor —
|
||||
// structurally, a card's clicks can never reach its create. This pins that structure.
|
||||
#expect(store.transient.newCardPlaceholder == nil,
|
||||
"a double-click on a card must not open the lane's placeholder")
|
||||
#expect(store.selection.ids.contains(target))
|
||||
}
|
||||
|
||||
@Test("An empty-space double-click opens the placeholder and keeps the lane selected")
|
||||
func emptySpaceDoubleClickCreatesThePlaceholder() throws {
|
||||
let fixture = try makeFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let board = try host(fixture)
|
||||
let store = board.store
|
||||
let emptySpacePoint = try #require(findEmptySpacePoint(on: board),
|
||||
"no probe point selected lane 0's empty space")
|
||||
|
||||
// A lone empty-space click first: the lane's own selection latency, the defect's original
|
||||
// surface. Measured ~475 ms before the fix — and unbounded on a surface that carries a
|
||||
// two-tap recogniser without a drag source, which is why the layer branches one tap on
|
||||
// `PointerClick.count` instead. The pointer rests live near the click, as a real one does.
|
||||
post(.leftMouseDown, at: emptySpacePoint, in: board.window, clicks: 1)
|
||||
pump(0.02)
|
||||
post(.leftMouseUp, at: emptySpacePoint, in: board.window, clicks: 1)
|
||||
let lone = try #require(
|
||||
waitForWithMotion(at: emptySpacePoint, in: board.window, timeout: 2.0) { !store.selection.isEmpty },
|
||||
"a lone empty-space click never selected the lane"
|
||||
)
|
||||
print(String(format: "── lone empty-space click → lane selected: %.0f ms", lone))
|
||||
#expect(lone < 250, "empty-space click → selection took \(Int(lone)) ms")
|
||||
#expect(store.selection.ids == [ItemID(rawValue: laneName(0))])
|
||||
store.clearSelection()
|
||||
pump(0.8)
|
||||
|
||||
// The pair: first click selects, second creates — and the first click's selection
|
||||
// survives, because the second click is the create alone, never also the toggle.
|
||||
click(at: emptySpacePoint, in: board.window, clicks: 1)
|
||||
let firstClick = try #require(waitFor(0.5) { !store.selection.isEmpty },
|
||||
"the pair's first click should select the lane")
|
||||
print(String(format: "── pair's first click → lane selected: %.0f ms", firstClick))
|
||||
click(at: emptySpacePoint, in: board.window, clicks: 2)
|
||||
_ = waitFor(1.0) { store.transient.newCardPlaceholder != nil }
|
||||
board.settle(turns: 2)
|
||||
|
||||
let placeholder = try #require(store.transient.newCardPlaceholder,
|
||||
"the empty-space double-click should open the placeholder")
|
||||
#expect(placeholder.laneID == ItemID(rawValue: laneName(0)))
|
||||
// The first click of the pair selected the lane; the second is the create alone
|
||||
// (`PointerClick.count` branches it away from the toggle), so the selection survives.
|
||||
#expect(store.selection.ids == [ItemID(rawValue: laneName(0))],
|
||||
"the pair's first click's selection should survive, selection \(store.selection.ids)")
|
||||
}
|
||||
|
||||
@Test("A right-click on the heels of a left click reaches its menu without the wait")
|
||||
func rightClickMenuAfterAClick() throws {
|
||||
let fixture = try makeFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let board = try host(fixture)
|
||||
let store = board.store
|
||||
|
||||
let observer = NotificationCenter.default.addObserver(
|
||||
forName: NSMenu.didBeginTrackingNotification, object: nil, queue: nil
|
||||
) { note in
|
||||
if MenuProbe.beganAt == nil { MenuProbe.beganAt = CACurrentMediaTime() }
|
||||
guard let menu = note.object as? NSMenu else { return }
|
||||
nonisolated(unsafe) let pending = menu
|
||||
// .common fires during menu tracking, which is what ends it — without this the
|
||||
// dispatch below never returns.
|
||||
let timer = Timer(timeInterval: 0.1, repeats: false) { _ in
|
||||
pending.cancelTrackingWithoutAnimation()
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
}
|
||||
defer { NotificationCenter.default.removeObserver(observer) }
|
||||
|
||||
func rightClick(_ label: String) -> Double? {
|
||||
MenuProbe.beganAt = nil
|
||||
let armed = CACurrentMediaTime()
|
||||
post(.rightMouseDown, at: cardPoint, in: board.window, clicks: 1)
|
||||
pump(0.02)
|
||||
post(.rightMouseUp, at: cardPoint, in: board.window, clicks: 1)
|
||||
_ = waitFor(2.0) { MenuProbe.beganAt != nil }
|
||||
let latency = MenuProbe.beganAt.map { ($0 - armed) * 1000 }
|
||||
print(latency.map { String(format: "── right-click → menu (%@): %.0f ms", label, $0) }
|
||||
?? "── right-click → menu (\(label)): NEVER")
|
||||
return latency
|
||||
}
|
||||
|
||||
// The first menu open in a process pays a one-time AppKit warmup (~350 ms) — spent here,
|
||||
// unasserted, so the pinned figures below measure the steady state users live in.
|
||||
_ = rightClick("cold, first menu in the process")
|
||||
pump(1.5)
|
||||
for gap in [0.1, 0.4, 0.8] {
|
||||
click(at: cardPoint, in: board.window, clicks: 1)
|
||||
_ = waitFor(1.0) { !store.selection.isEmpty }
|
||||
pump(gap)
|
||||
let label = String(format: "%.1f s after a left click", gap)
|
||||
let latency = try #require(rightClick(label), "the context menu never began tracking")
|
||||
// ~464 ms before the fix: pending click disambiguation deferred the menu too.
|
||||
#expect(latency < 250, "right-click → menu (\(label)) took \(Int(latency)) ms")
|
||||
store.clearSelection()
|
||||
pump(1.2)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user