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:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user