An alignment tick when the drop proposal lands somewhere new

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
This commit is contained in:
2026-08-03 13:41:06 -04:00
parent 43e6ad229e
commit c60553f17f
2 changed files with 132 additions and 0 deletions
+14
View File
@@ -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
+118
View File
@@ -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")
}
}