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:
@@ -351,6 +351,56 @@ final class DragSession {
|
||||
/// `CommittedHold`.
|
||||
private(set) var hold: CommittedHold?
|
||||
|
||||
// MARK: The modifier flip
|
||||
|
||||
/// **The stationary-flip nudge**: bumped once for every change to ⌥/⌘ while a session is in
|
||||
/// flight and unsettled (`ModifierFlipSource`, armed at `begin` and stopped at `end`).
|
||||
///
|
||||
/// The whole of what this object can say about a flip, and deliberately so. The *effect* of a
|
||||
/// flip is a re-proposal, and re-proposing needs a board window's geometry — which board is
|
||||
/// under the cursor, where its lanes are drawn — none of which an app-wide session has. So the
|
||||
/// flip is published as a counter and the hovered board's own drop context turns it back into
|
||||
/// the one shared retarget (`BoardDropContext.retargetAfterModifierFlip`), which is the same
|
||||
/// seam the autoscroll driver's every scroll step goes through.
|
||||
///
|
||||
/// Observed, unlike everything else the event handlers write here: it exists to invalidate a
|
||||
/// board window's body. That costs a strip body pass per **keystroke**, not per mouse sample —
|
||||
/// and the flip changes `hiddenMembers`, so that pass was happening anyway.
|
||||
private(set) var modifierGeneration = 0
|
||||
|
||||
/// The board surface that last resolved this session's proposal — which window, and which of
|
||||
/// its drop surfaces — so a flip can re-run *that* retarget rather than guess at one.
|
||||
///
|
||||
/// **The window is matched by registry identity, never by board root.** Two windows open on one
|
||||
/// board share a store and a root but not a `LaneDropRegistry` (the cache's key already turns on
|
||||
/// exactly this), and only one of them has the cursor over it; a root comparison would have the
|
||||
/// other one retargeting against a cursor that is nowhere near its lanes.
|
||||
///
|
||||
/// **Weak**, because a board window can close mid-drag: the session outlives it, and a flip
|
||||
/// afterwards simply finds no one to answer — which is the honest answer, since the surface that
|
||||
/// was resolving the proposal is gone.
|
||||
///
|
||||
/// `@ObservationIgnored` for `LaneDropRegistry`'s own reason: it is written from *event*
|
||||
/// handlers, on every sample of every drag, and a body that re-ran for it would be the animation
|
||||
/// feedback loop the drop model exists to avoid.
|
||||
@ObservationIgnored private(set) var retargetOrigin: RetargetOrigin?
|
||||
|
||||
/// Which board window ran the last retarget, and which of its surfaces.
|
||||
struct RetargetOrigin {
|
||||
|
||||
/// The three surfaces that resolve a proposal — the three retargets a flip can replay.
|
||||
/// Each names the function that recorded it: `.strip` is `retargetLanes`, `.lane` is
|
||||
/// `retargetCards(inLane:)`, `.trash` is `retargetTrash`.
|
||||
enum Surface: Equatable {
|
||||
case strip
|
||||
case lane(ItemID)
|
||||
case trash
|
||||
}
|
||||
|
||||
weak var registry: LaneDropRegistry?
|
||||
var surface: Surface
|
||||
}
|
||||
|
||||
// MARK: The external file mode
|
||||
|
||||
/// Where an external Finder file drag would land, or `nil` when there is none in flight or it is
|
||||
@@ -368,13 +418,31 @@ final class DragSession {
|
||||
@ObservationIgnored private var fileWatchdog: Task<Void, Never>?
|
||||
@ObservationIgnored private var holdTimeoutTask: Task<Void, Never>?
|
||||
|
||||
/// Where the flips come from, and the running watch — armed at `begin`, stopped at `end`, and
|
||||
/// nowhere else (see `armFlipWatch`).
|
||||
@ObservationIgnored private let flipSource: any ModifierFlipSource
|
||||
@ObservationIgnored private var flipWatch: (any ModifierFlipWatch)?
|
||||
|
||||
/// The ⌥/⌘ state the last flip reported, so a `.flagsChanged` that moved neither — ⇧ for the
|
||||
/// marquee's own grammar, ⌃, caps lock, a function key — costs nothing at all.
|
||||
@ObservationIgnored private var flipFlags: NSEvent.ModifierFlags = []
|
||||
|
||||
/// How long this session's holds may stand with no snapshot arriving — `CommittedHold.timeout`,
|
||||
/// and a `var` for one reason only: the discard path is a `Task` sleeping on the main actor, and
|
||||
/// a test that had to wait the real figure out would be a 1.5 s wall clock in the suite
|
||||
/// (`DragSessionTests`). Nothing in the app writes it.
|
||||
@ObservationIgnored var holdTimeout: Duration = CommittedHold.timeout
|
||||
|
||||
init() {}
|
||||
/// **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]
|
||||
|
||||
/// `flipSource` is defaulted to the shipping local monitor; it is a parameter for the reason
|
||||
/// `DragAutoScroller`'s tick source is one — the real thing needs a live `NSApplication` event
|
||||
/// stream, which a test bundle has no way to feed (`ModifierFlipTests`).
|
||||
init(flipSource: any ModifierFlipSource = LocalModifierFlipSource()) {
|
||||
self.flipSource = flipSource
|
||||
}
|
||||
|
||||
// MARK: Queries
|
||||
|
||||
@@ -411,9 +479,12 @@ final class DragSession {
|
||||
///
|
||||
/// Reading `operation` here is what makes the flip visible at all: the property is observed, so
|
||||
/// every surface that builds a resting layout off this method re-renders when it changes. The
|
||||
/// flip lands on the next `dropUpdated`, since that is where the operation is re-resolved; a
|
||||
/// modifier pressed with the mouse perfectly still waits for the next motion (Backlog ▸
|
||||
/// Stationary modifier flips).
|
||||
/// flip lands on the next `dropUpdated`, since that is where the operation is re-resolved —
|
||||
/// **or, with the mouse perfectly still, on the flip's own nudge**: drop callbacks arrive only
|
||||
/// while the mouse moves, so a `.flagsChanged` monitor bumps `modifierGeneration` and the
|
||||
/// hovered board re-runs the retarget the last callback ran
|
||||
/// (`BoardDropContext.retargetAfterModifierFlip`). A stationary ⌥ is therefore answered at the
|
||||
/// keystroke rather than at the next mouse sample.
|
||||
///
|
||||
/// **The hold freezes the answer**, because `resolveOperation` refuses to move once a release
|
||||
/// has settled: a settled copy keeps its originals on screen and a settled move keeps them
|
||||
@@ -596,7 +667,9 @@ final class DragSession {
|
||||
// The reload-resolved drag set: vanished members leave it silently, which is what
|
||||
// `survivors` reads and what "an emptied drag cancels itself" is stated in terms of.
|
||||
source.transient.dragMembers = ItemReferenceSet(ids: memberSet, container: container)
|
||||
self.retargetOrigin = nil
|
||||
armWatchdog()
|
||||
armFlipWatch()
|
||||
}
|
||||
|
||||
/// Records a new proposal. `nil` withdraws it — rule 2's "the shadow withdraws".
|
||||
@@ -609,6 +682,28 @@ final class DragSession {
|
||||
proposal = target
|
||||
}
|
||||
|
||||
/// Records which board window's surface just resolved the proposal (`RetargetOrigin`) — the
|
||||
/// address a stationary modifier flip replays its retarget at.
|
||||
///
|
||||
/// Written by the three retargets themselves rather than by their five callers, so the drop
|
||||
/// delegates, the strip's fall-through and the autoscroll driver cannot disagree about where a
|
||||
/// flip should land any more than they can disagree about where the drop should.
|
||||
///
|
||||
/// **Recorded even when the retarget goes on to hold**, which is the point: a cursor over a gap
|
||||
/// or a dead region leaves the proposal exactly where it was, and a flip there must re-ask the
|
||||
/// same surface the same question — with the modifiers now saying something else.
|
||||
///
|
||||
/// Unchanged addresses are not re-stored, `propose`'s own habit and for a sharper reason here:
|
||||
/// this runs on every mouse sample *and* every autoscroll frame, and the steady state of a drag
|
||||
/// is the same surface answering over and over — a weak reference restored per frame for no
|
||||
/// change at all is exactly the kind of hot-path cost the drag model has been shedding.
|
||||
func noteRetarget(_ surface: RetargetOrigin.Surface, registry: LaneDropRegistry) {
|
||||
if let origin = retargetOrigin, origin.surface == surface, origin.registry === registry {
|
||||
return
|
||||
}
|
||||
retargetOrigin = RetargetOrigin(registry: registry, surface: surface)
|
||||
}
|
||||
|
||||
/// Re-resolves the effective operation against the board under the cursor and the modifiers
|
||||
/// **right now**, and hands it back for the `DropProposal` the badge tracks.
|
||||
///
|
||||
@@ -651,8 +746,13 @@ final class DragSession {
|
||||
operation = .move
|
||||
sourceStore = nil
|
||||
sourceRoot = nil
|
||||
retargetOrigin = nil
|
||||
watchdog?.cancel()
|
||||
watchdog = nil
|
||||
// The monitor's lifetime is the session's, and this is the sentence that makes it true:
|
||||
// every path that ends a drag — a delegate's cancel, the button-up belt, the watchdog, the
|
||||
// hold's hand-off, the hold's timeout — funnels through here.
|
||||
stopFlipWatch()
|
||||
endHold()
|
||||
}
|
||||
|
||||
@@ -761,6 +861,56 @@ final class DragSession {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: The modifier flip — the watch, and what a flip means
|
||||
|
||||
/// Starts watching ⌥/⌘ for this session (`ModifierFlipSource`).
|
||||
///
|
||||
/// **Armed at `begin` and stopped at `end`, deliberately nowhere else.** The watchdog already
|
||||
/// guarantees `end` runs for every session macOS never reports the finish of, so tying the
|
||||
/// monitor to that same bracket is what makes "it cannot outlive the drag" structural rather
|
||||
/// than a list of call sites to keep in step — the resting-layout cache's precedent exactly.
|
||||
/// A previous watch is stopped first for `armWatchdog`'s reason: a second `begin` with a session
|
||||
/// somehow still standing must not leave the first one's monitor installed.
|
||||
///
|
||||
/// The **committed hold** deliberately does not stop it: a hold is still this session, and the
|
||||
/// freeze it applies is `resolveOperation`'s and `propose`'s, asked at the flip rather than
|
||||
/// spelled a second time in the lifecycle (`noteFlip` restates it anyway, so a flip during a
|
||||
/// settle costs not even a body pass).
|
||||
private func armFlipWatch() {
|
||||
stopFlipWatch()
|
||||
// The baseline is the modifier state the drag is *starting* under — a ⌥ that was already
|
||||
// down at pickup is not a flip, and `DragLocality` has already seen it.
|
||||
flipFlags = NSEvent.modifierFlags.intersection(Self.operationFlags)
|
||||
flipWatch = flipSource.watch { [weak self] flags in
|
||||
self?.noteFlip(flags)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopFlipWatch() {
|
||||
flipWatch?.stop()
|
||||
flipWatch = nil
|
||||
}
|
||||
|
||||
/// A `.flagsChanged` arrived. Publishes the nudge the hovered board turns back into a
|
||||
/// re-proposal, and only when something the operation depends on actually moved.
|
||||
///
|
||||
/// **The event's location is deliberately unread.** A flip is not a pointer event: the mouse is
|
||||
/// exactly where the last drop callback left it, and every retarget reads the *physical* cursor
|
||||
/// through its own window (`BoardDropContext.globalCursor`) rather than any event's coordinates.
|
||||
/// That is the same reason the autoscroll driver needs no events at all to keep re-proposing.
|
||||
///
|
||||
/// **A settled release ignores flips**, the third face of the freeze `resolveOperation` and
|
||||
/// `propose` already wear: the write named an operation and the overlay draws that operation
|
||||
/// until the echo lands, so a ⌥ released between the drop and the reload must not re-lift
|
||||
/// originals the write is leaving in place.
|
||||
private func noteFlip(_ flags: NSEvent.ModifierFlags) {
|
||||
let relevant = flags.intersection(Self.operationFlags)
|
||||
guard relevant != flipFlags else { return }
|
||||
flipFlags = relevant
|
||||
guard isActive, hold == nil else { return }
|
||||
modifierGeneration &+= 1
|
||||
}
|
||||
|
||||
/// The file mode's own watchdog, and its only guaranteed termination path.
|
||||
///
|
||||
/// An external session is not ours to end: no `performDrop` runs when the user drops the files
|
||||
@@ -785,3 +935,72 @@ final class DragSession {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Watching the modifiers
|
||||
|
||||
/// Where `DragSession` learns that ⌥ or ⌘ moved — the seam under the local `.flagsChanged` monitor,
|
||||
/// and `DragAutoScrollTickSource`'s exact cousin: the shipping implementation needs a live
|
||||
/// `NSApplication` event stream, which a test bundle has no way to feed.
|
||||
///
|
||||
/// `Sendable` so a session's stored source is, like the tick source's; the watch it hands back is
|
||||
/// main-actor state and the handler runs there, which is where every drag input is read.
|
||||
protocol ModifierFlipSource: Sendable {
|
||||
|
||||
/// Starts reporting modifier changes, until the returned watch is stopped. The flags are the
|
||||
/// event's own — the *whole* set, since deciding which bits matter is the session's job
|
||||
/// (`DragSession.operationFlags`).
|
||||
@MainActor
|
||||
func watch(_ onFlip: @escaping @MainActor (NSEvent.ModifierFlags) -> Void) -> any ModifierFlipWatch
|
||||
}
|
||||
|
||||
/// A running modifier watch. `stop()` is what retires the underlying monitor, and it is idempotent
|
||||
/// so the session's teardown can call it on every path without asking whether one is installed.
|
||||
@MainActor
|
||||
protocol ModifierFlipWatch: AnyObject {
|
||||
func stop()
|
||||
}
|
||||
|
||||
/// The shipping source: a **local** `NSEvent` monitor for `.flagsChanged`.
|
||||
///
|
||||
/// Local rather than global, for two reasons that point the same way. A global monitor for keyboard
|
||||
/// events needs Accessibility permission — an enormous ask for a drag affordance — and it would
|
||||
/// report modifiers pressed while another app is frontmost, which is not a flip in *this* drag at
|
||||
/// all. The events a drag actually needs are the ones being dispatched to this application while it
|
||||
/// holds the session.
|
||||
struct LocalModifierFlipSource: ModifierFlipSource {
|
||||
|
||||
@MainActor
|
||||
func watch(_ onFlip: @escaping @MainActor (NSEvent.ModifierFlags) -> Void) -> any ModifierFlipWatch {
|
||||
LocalModifierFlipWatch(onFlip)
|
||||
}
|
||||
}
|
||||
|
||||
/// The monitor token's owner, and the one place it is removed.
|
||||
///
|
||||
/// `addLocalMonitorForEvents` hands back an opaque token that `removeMonitor` **must** be given —
|
||||
/// AppKit retains the handler until it is, so a token dropped on the floor is a block that keeps
|
||||
/// firing for the rest of the process. Holding it in an object whose only method retires it is what
|
||||
/// makes the removal unmissable: `DragSession.end()` stops the watch, and every way a drag can
|
||||
/// finish goes through `end()`.
|
||||
@MainActor
|
||||
private final class LocalModifierFlipWatch: ModifierFlipWatch {
|
||||
|
||||
private var token: Any?
|
||||
|
||||
init(_ onFlip: @escaping @MainActor (NSEvent.ModifierFlags) -> Void) {
|
||||
token = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { event in
|
||||
// Local monitors run on the main thread, before the event reaches its window.
|
||||
MainActor.assumeIsolated { onFlip(event.modifierFlags) }
|
||||
// **Returned unchanged, always.** ⌥ and ⌘ mean things to the rest of the app — the
|
||||
// click grammar, the menu bar's key equivalents — and a monitor that swallowed them
|
||||
// would be reading the drag's modifiers by taking them away from everything else.
|
||||
return event
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard let token else { return }
|
||||
NSEvent.removeMonitor(token)
|
||||
self.token = nil
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user