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.
|
||||
|
||||
@@ -188,6 +188,21 @@ struct BoardView: View {
|
||||
.onChange(of: store.landedReloads) { _, _ in
|
||||
resize.handOff(against: store.snapshot.lanes)
|
||||
}
|
||||
// **The stationary modifier flip's re-proposal** (`BoardDropContext.retargetAfterModifierFlip`).
|
||||
// Drop callbacks arrive only while the mouse moves, so a ⌥ pressed against a still pointer
|
||||
// reaches the drag through the session's `.flagsChanged` watch instead — and lands back on
|
||||
// the one shared retarget here, in the board window whose surface resolved the proposal.
|
||||
//
|
||||
// Every open board window carries this handler and exactly one of them acts: the flip
|
||||
// replays against a `LaneDropRegistry`, which is this window's alone.
|
||||
//
|
||||
// **No transaction of its own**, deliberately: a flip must produce exactly what the next
|
||||
// mouse sample would have produced, and `dropUpdated` wraps nothing either. What moves is
|
||||
// keyed declaratively where it is drawn — the strip on its proposal, each lane on its shadow
|
||||
// run (03-board-ui.md § Motion, transactions keyed narrowly).
|
||||
.onChange(of: appModel.dragSession.modifierGeneration) { _, _ in
|
||||
dropContext.retargetAfterModifierFlip()
|
||||
}
|
||||
.trashPurgeAlert(store: store, confirmations: confirmations)
|
||||
// The board's own anchor for the Style… popover — the surface a board-targeted session hangs
|
||||
// off, since the board has no item to attach to (`styleEditorPresentation`'s `nil` anchor).
|
||||
|
||||
@@ -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