A stationary modifier flip answers at the keystroke — the third client of the one shared retarget
Modifiers were sampled only inside resolveOperation, reached from dropUpdated and the commit — and drop callbacks arrive only while the mouse moves, so ⌥ pressed against a still pointer changed nothing until the next twitch. DragSession now arms a local .flagsChanged watch for exactly the session's lifetime (begin arms, end stops, the watchdog guarantees end; ⇧/⌃/caps don't count, a settled hold swallows) and publishes modifierGeneration; the three retargets record which window's surface resolved the proposal (RetargetOrigin — weak registry identity, never board root, so cross-board and two-windows- one-board both answer correctly), and the hovered window replays that same retarget with the operation re-resolved FIRST, since the index space is a function of it. The monitor is injectable — the real one needs a live event stream no test bundle has. Drag-perf card d491e7d3. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -381,7 +381,11 @@ struct BoardDropContext {
|
||||
/// still "the lanes minus the run" every time this function actually runs, and `moveLanes` /
|
||||
/// `receiveLanes` keep the index space they always had.
|
||||
func retargetLanes() {
|
||||
guard session.isDraggingLanes, let cursor = stripCursor() else { return }
|
||||
guard session.isDraggingLanes else { return }
|
||||
// Before the cursor, so the flip's replay address is "the surface that got the event" rather
|
||||
// than "the surface that managed to answer" (`DragSession.noteRetarget`).
|
||||
session.noteRetarget(.strip, registry: registry)
|
||||
guard let cursor = stripCursor() else { return }
|
||||
let hidden = session.hiddenMembers(onBoardRooted: store.rootKey)
|
||||
let resting = store.snapshot.lanes.filter { !hidden.contains($0.id) }
|
||||
let restingUnits = resting.map { LaneLayoutMath.displayUnits(of: $0) }
|
||||
@@ -422,7 +426,12 @@ struct BoardDropContext {
|
||||
let span = DragSignposts.beginRetargetCards()
|
||||
defer { DragSignposts.endRetargetCards(span) }
|
||||
#endif
|
||||
guard session.isDraggingCards, let cursor = globalCursor() else { return }
|
||||
guard session.isDraggingCards else { return }
|
||||
// See `retargetLanes` — recorded before the cursor is read, and by the retarget rather than
|
||||
// by its callers, so the delegate, the strip's fall-through and the autoscroll driver all
|
||||
// leave the same address behind.
|
||||
session.noteRetarget(.lane(laneID), registry: registry)
|
||||
guard let cursor = globalCursor() else { return }
|
||||
guard let resting = session.restingLayouts.layout(
|
||||
inLane: laneID,
|
||||
of: store,
|
||||
@@ -484,13 +493,17 @@ struct BoardDropContext {
|
||||
/// A method rather than a property because it is not free of consequence: the operation is
|
||||
/// re-resolved against the modifiers *at this instant*, which is also what keeps the badge honest
|
||||
/// while the cursor sits over the column (`dropProposal`).
|
||||
func acceptsTrashDrop() -> Bool {
|
||||
///
|
||||
/// `modifiers` defaults to the live flags, which is the only thing the app ever passes — the
|
||||
/// parameter is `DragSession.resolveOperation`'s own seam, one rung out, so the refusal this
|
||||
/// gate exists for (⌥ over the column) is checkable without a keyboard (`ModifierFlipTests`).
|
||||
func acceptsTrashDrop(modifiers: NSEvent.ModifierFlags = NSEvent.modifierFlags) -> Bool {
|
||||
guard let sourceRoot = session.sourceRoot else { return false }
|
||||
return TrashDrop.accepts(
|
||||
kind: session.kind,
|
||||
container: session.container,
|
||||
isWithinBoard: sourceRoot == store.rootKey,
|
||||
operation: session.resolveOperation(destinationRoot: store.rootKey),
|
||||
operation: session.resolveOperation(destinationRoot: store.rootKey, modifiers: modifiers),
|
||||
isTrashShown: store.transient.isTrashVisible,
|
||||
acceptsMutations: store.acceptsBoardMutations
|
||||
)
|
||||
@@ -506,9 +519,19 @@ struct BoardDropContext {
|
||||
/// trash proposes nothing for you": the column declines to be a target, it does not cancel the
|
||||
/// drag the user is still holding. So a lane drag reorders across the column exactly as it always
|
||||
/// did, and an ⌥-copy released over it still lands where its shadows are.
|
||||
func retargetTrash() {
|
||||
func retargetTrash(modifiers: NSEvent.ModifierFlags = NSEvent.modifierFlags) {
|
||||
// A session, or there is nothing to record an address for. Provably a no-op otherwise —
|
||||
// every branch below is kind-guarded one level down — and stated so that "an origin means a
|
||||
// drag" holds (`DragSession.noteRetarget`).
|
||||
guard session.isActive else { return }
|
||||
revalidateProposal()
|
||||
guard acceptsTrashDrop() else {
|
||||
// The fall-through below may overwrite this address with the surface that actually answered,
|
||||
// and that is exactly right: a flip replays whatever resolved the proposal last. The one
|
||||
// case this gate exists for keeps it — a cursor over the column resolves to no lane, so the
|
||||
// strip's card fall-through holds without recording, and the column stays the address the
|
||||
// ⌥-release has to be re-asked at.
|
||||
session.noteRetarget(.trash, registry: registry)
|
||||
guard acceptsTrashDrop(modifiers: modifiers) else {
|
||||
retargetFromStrip()
|
||||
return
|
||||
}
|
||||
@@ -519,6 +542,61 @@ struct BoardDropContext {
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: Retargeting — the modifier flip
|
||||
|
||||
/// **The stationary modifier flip's answer** (04-interactions.md ▸ Drag and drop: "⌥ always
|
||||
/// forces copy and ⌘ always forces move … the badge tracks the effective operation live").
|
||||
///
|
||||
/// The modifiers are sampled inside `resolveOperation`, which is reached from `dropUpdated` and
|
||||
/// the commit — and drop callbacks arrive only while the mouse **moves**. So ⌥ pressed with the
|
||||
/// pointer perfectly still used to change nothing visible until the next twitch: the source
|
||||
/// board's originals stayed lifted, the trash kept refusing (or kept offering) against the old
|
||||
/// operation, and the shadows stood in an index space the release would no longer count in.
|
||||
/// This is the missing sample, arriving from the keyboard instead of from the mouse.
|
||||
///
|
||||
/// **The third client of the one shared retarget** (DRAG-REORDER.md § Edge autoscroll: "the
|
||||
/// lane's drop delegate and the autoscroll driver must go through one shared retarget, so they
|
||||
/// can never disagree"). It resolves nothing itself — it re-runs the retarget that resolved the
|
||||
/// standing proposal, at the surface that resolved it (`DragSession.RetargetOrigin`), so a flip
|
||||
/// and a mouse sample land in exactly the same place by construction.
|
||||
///
|
||||
/// **No location comes off the flip event, and none is needed.** Every retarget reads the
|
||||
/// physical cursor through this window (`globalCursor`), which is precisely why the autoscroll
|
||||
/// driver can re-propose against a stationary pointer while the content moves under it; here the
|
||||
/// content stands still and the *operation* moves instead.
|
||||
///
|
||||
/// Three guards, in the order that makes each one cheap:
|
||||
///
|
||||
/// - **A settled release is past retargeting.** `resolveOperation` and `propose` both freeze
|
||||
/// under a hold, so this would be inert anyway; refusing outright means a flip during the
|
||||
/// settle costs nothing at all, and states the freeze where it can be read.
|
||||
/// - **Only the window under the cursor answers.** The origin names a `LaneDropRegistry`, which
|
||||
/// is per board *window* — so a cross-board drag re-proposes against the board being hovered
|
||||
/// rather than the one it was picked up from, two windows on one board do not both answer, and
|
||||
/// a window closed mid-drag drops out silently (the reference is weak).
|
||||
/// - **The operation is re-resolved before the retarget, not after it.** `dropUpdated` runs them
|
||||
/// the other way round and can afford to: at 60+ samples a second the next event repairs the
|
||||
/// one-sample lag. A flip gets one shot, and the retarget's index space is a function of the
|
||||
/// operation (`retargetCards` → `hiddenMembers`), so the operation has to be current *first*
|
||||
/// or the shadows would land in the space the flip just left.
|
||||
func retargetAfterModifierFlip(modifiers: NSEvent.ModifierFlags = NSEvent.modifierFlags) {
|
||||
guard session.isActive, session.hold == nil else { return }
|
||||
guard let origin = session.retargetOrigin, origin.registry === registry else { return }
|
||||
|
||||
session.resolveOperation(destinationRoot: store.rootKey, modifiers: modifiers)
|
||||
|
||||
switch origin.surface {
|
||||
case .strip:
|
||||
retargetFromStrip()
|
||||
case let .lane(laneID):
|
||||
// The lane delegate's own pair, in its order: rule 2 first, then the masonry's zones.
|
||||
revalidateProposal()
|
||||
retargetCards(inLane: laneID)
|
||||
case .trash:
|
||||
retargetTrash(modifiers: modifiers)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Retargeting — external Finder file sessions
|
||||
|
||||
/// Whether `info` is an **external Finder file** session rather than one of ours.
|
||||
|
||||
Reference in New Issue
Block a user