From c60553f17f3e60cca8efa1e5f9314d98c372bd7c Mon Sep 17 00:00:00 2001 From: rzen Date: Mon, 3 Aug 2026 13:41:06 -0400 Subject: [PATCH] An alignment tick when the drop proposal lands somewhere new MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NSHapticFeedbackManager .alignment at propose()'s change edge — the one funnel every retarget commits through, whose existing guards (value-equal refusal, hold freeze) already make firings per-landing- spot rather than per-pixel; withdrawal stays silent (losing a target is not an alignment), and the external-file mode gets nothing. Force Touch hardware only, silent no-op elsewhere; performanceTime .default syncs the tick to the reflow the change triggers. The performer rides an injectable seam beside holdTimeout for the same reason it has one. Drag-perf card 11b85111 — the cluster's last. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97 --- Kanban/UI/Board/DragSession.swift | 14 ++++ KanbanTests/DragSessionTests.swift | 118 +++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/Kanban/UI/Board/DragSession.swift b/Kanban/UI/Board/DragSession.swift index 95ce03f..f31aff8 100644 --- a/Kanban/UI/Board/DragSession.swift +++ b/Kanban/UI/Board/DragSession.swift @@ -433,6 +433,13 @@ final class DragSession { /// (`DragSessionTests`). Nothing in the app writes it. @ObservationIgnored var holdTimeout: Duration = CommittedHold.timeout + /// Fires the alignment tick for a genuine new landing spot (`propose`). A `var` for the reason + /// `holdTimeout` is one: the real `NSHapticFeedbackManager` call needs a Force Touch trackpad a + /// test bundle has no way to feed, so a test substitutes a closure that counts firings instead. + @ObservationIgnored var hapticTick: () -> Void = { + NSHapticFeedbackManager.defaultPerformer.perform(.alignment, performanceTime: .default) + } + /// **The two flags the effective operation is a function of** — `DragLocality.operation` reads /// these and nothing else, so these are the whole of what "a flip" means here. static let operationFlags: NSEvent.ModifierFlags = [.option, .command] @@ -680,6 +687,13 @@ final class DragSession { func propose(_ target: DropTarget?) { guard isActive, hold == nil, proposal != target else { return } proposal = target + // One tick per genuine new landing spot, never per pixel: the hysteresis/dead-region model + // above already makes retargets sparse, which is exactly the case Apple gives `.alignment` + // for rather than the "don't overuse haptics" one a per-sample tick would land in. A + // withdrawal (`target == nil`) is losing a target, not landing on one, so it stays silent. + if target != nil { + hapticTick() + } } /// Records which board window's surface just resolved the proposal (`RetargetOrigin`) — the diff --git a/KanbanTests/DragSessionTests.swift b/KanbanTests/DragSessionTests.swift index 74e18c5..efb51e2 100644 --- a/KanbanTests/DragSessionTests.swift +++ b/KanbanTests/DragSessionTests.swift @@ -929,3 +929,121 @@ struct DragRestingLayoutTests { #expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1]) } } + +// MARK: - The alignment haptic + +/// **A tick per genuine new landing spot, never per pixel** (`DragSession.propose`): the +/// hysteresis/dead-region model above already makes proposal changes sparse, which is what lets a +/// tick fire on every one of them without landing in Apple's own "don't overuse haptics" caution for +/// `.alignment`. +/// +/// The seam is `hapticTick`, a closure substituted for the real `NSHapticFeedbackManager` call — +/// `holdTimeout`'s own reason: a test bundle has no Force Touch trackpad to drive, so a test counts +/// firings instead of feeling them. +@MainActor +@Suite("The alignment haptic") +struct HapticTickTests { + + private static let lane1 = ItemID(rawValue: Ident.lane1) + private static let card1 = ItemID(rawValue: Ident.card1) + + private func makeBoard() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) + return fixture + } + + /// A picked-up card session with the tick wired to a counter instead of the real performer. + private func pickedUp(_ store: BoardStore) -> (session: DragSession, ticks: () -> Int) { + let session = DragSession() + var count = 0 + session.hapticTick = { count += 1 } + session.beginCards( + [Self.card1], + folders: [store.rootURL + .appendingPathComponent(Ident.lane1, isDirectory: true) + .appendingPathComponent(Ident.card1, isDirectory: true)], + heights: [44], + container: .board, + source: store + ) + return (session, { count }) + } + + private func target(_ store: BoardStore, index: Int) -> DropTarget { + DropTarget(boardRoot: store.rootKey, container: .lane(Self.lane1), index: index) + } + + @Test("A fresh proposal ticks once") + func newProposalTicksOnce() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let (session, ticks) = pickedUp(store) + + session.propose(target(store, index: 0)) + + #expect(ticks() == 1) + } + + @Test("Re-proposing the same slot ticks zero more times") + func sameValueReproposeDoesNotTick() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let (session, ticks) = pickedUp(store) + + session.propose(target(store, index: 0)) + session.propose(target(store, index: 0)) + + #expect(ticks() == 1, "the second propose named the slot the first already stood at") + } + + @Test("Withdrawing the proposal does not tick — losing a target isn't an alignment") + func withdrawalDoesNotTick() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let (session, ticks) = pickedUp(store) + + session.propose(target(store, index: 0)) + let afterLanding = ticks() + + session.propose(nil) + + #expect(ticks() == afterLanding, "the withdrawal itself must add nothing") + } + + @Test("A proposal refused by a committed hold does not tick") + func proposeUnderHoldDoesNotTick() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let (session, ticks) = pickedUp(store) + + session.propose(target(store, index: 0)) + session.commit(into: store) + let afterCommit = ticks() + + session.propose(target(store, index: 1)) + + #expect(ticks() == afterCommit, "propose already refuses once a release has settled") + } + + @Test("A change from one slot to another ticks once") + func slotToSlotChangeTicksOnce() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let (session, ticks) = pickedUp(store) + + session.propose(target(store, index: 0)) + let afterFirstLanding = ticks() + + session.propose(target(store, index: 1)) + + #expect(ticks() == afterFirstLanding + 1, "slot A to slot B is one new landing spot") + } +}