diff --git a/CHANGELOG.md b/CHANGELOG.md index 74dabbc..49dccab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Kanban/App/TemplateChooserView.swift b/Kanban/App/TemplateChooserView.swift index 875deef..4ee4fef 100644 --- a/Kanban/App/TemplateChooserView.swift +++ b/Kanban/App/TemplateChooserView.swift @@ -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 ▸ diff --git a/Kanban/UI/Board/LaneView.swift b/Kanban/UI/Board/LaneView.swift index eac351a..d2e4083 100644 --- a/Kanban/UI/Board/LaneView.swift +++ b/Kanban/UI/Board/LaneView.swift @@ -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. diff --git a/Kanban/UI/Board/SelectionClicks.swift b/Kanban/UI/Board/SelectionClicks.swift index 3b004b6..87bfd07 100644 --- a/Kanban/UI/Board/SelectionClicks.swift +++ b/Kanban/UI/Board/SelectionClicks.swift @@ -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 diff --git a/Kanban/UI/Board/TrashLaneView.swift b/Kanban/UI/Board/TrashLaneView.swift index aa448da..8963df4 100644 --- a/Kanban/UI/Board/TrashLaneView.swift +++ b/Kanban/UI/Board/TrashLaneView.swift @@ -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 } } } diff --git a/Kanban/UI/Card/CardAttachmentsSection.swift b/Kanban/UI/Card/CardAttachmentsSection.swift index 5bd5bdf..1153cba 100644 --- a/Kanban/UI/Card/CardAttachmentsSection.swift +++ b/Kanban/UI/Card/CardAttachmentsSection.swift @@ -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 diff --git a/KanbanTests/PointerLatencyTests.swift b/KanbanTests/PointerLatencyTests.swift new file mode 100644 index 0000000..ed70975 --- /dev/null +++ b/KanbanTests/PointerLatencyTests.swift @@ -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.. 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.. 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) + } + } +}