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.
|
/// (`DragSessionTests`). Nothing in the app writes it.
|
||||||
@ObservationIgnored var holdTimeout: Duration = CommittedHold.timeout
|
@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
|
/// **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.
|
/// these and nothing else, so these are the whole of what "a flip" means here.
|
||||||
static let operationFlags: NSEvent.ModifierFlags = [.option, .command]
|
static let operationFlags: NSEvent.ModifierFlags = [.option, .command]
|
||||||
@@ -680,6 +687,13 @@ final class DragSession {
|
|||||||
func propose(_ target: DropTarget?) {
|
func propose(_ target: DropTarget?) {
|
||||||
guard isActive, hold == nil, proposal != target else { return }
|
guard isActive, hold == nil, proposal != target else { return }
|
||||||
proposal = target
|
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
|
/// 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])
|
#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