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:
2026-08-03 13:24:53 -04:00
parent 1fc8aaa249
commit 1ffb64913b
5 changed files with 885 additions and 10 deletions
+561
View File
@@ -0,0 +1,561 @@
import AppKit
import Foundation
import Testing
@testable import Kanban
/// **The stationary modifier flip** (04-interactions.md Drag and drop: " always forces copy and
/// always forces move the badge tracks the effective operation live").
///
/// The effective operation is sampled inside `DragSession.resolveOperation`, which is reached from
/// `dropUpdated` and from the commit and `DropDelegate` callbacks arrive only while the mouse
/// **moves**. So a modifier pressed against a perfectly still pointer used to change nothing until
/// the next twitch: the source board's originals stayed lifted (`hiddenMembers` follows the
/// operation since the copy-redraw ruling), the trash column kept refusing a copy it would now take,
/// and the shadows stood in an index space the release no longer counted in.
///
/// The fix is a `.flagsChanged` watch whose lifetime is the drag's, and whose only output is a
/// counter; the board window under the cursor turns that counter back into **the one shared
/// retarget** (`BoardDropContext.retargetAfterModifierFlip`) the third client of the seam
/// DRAG-REORDER.md § Edge autoscroll opened for the autoscroll driver.
///
/// Two halves, tested at the two seams they live at:
///
/// - the **watch's lifetime** and what counts as a flip, on `DragSession`, through a scripted
/// source the shipping one is a local `NSEvent` monitor, which needs a live application event
/// stream a test bundle cannot feed (`DragAutoScrollDriverTests`' scripted tick source exactly);
/// - the **funnel**, on `BoardDropContext`, through the modifier seam `resolveOperation` already
/// established: the flags are a defaulted parameter, so the whole re-proposal is checkable
/// without a keyboard.
///
/// What is *not* here, because no unit can hold it: whether the system's own drag badge tracks
/// modifiers without our help (`NSDraggingSource` ANDs modifier-driven operations into the mask
/// unless `ignoreModifierKeysWhileDragging`), and whether a local monitor is delivered inside
/// AppKit's drag-tracking loop at all. Both are live-verify items; the source is a seam precisely so
/// the second one has somewhere to be answered.
// MARK: - The scripted source
/// A stand-in for `LocalModifierFlipSource` whose flags the test presses, and which records the one
/// thing the real source must do when a drag ends: it removes the monitor.
@MainActor
private final class ScriptedFlips: ModifierFlipSource {
/// How many monitors this source has installed, and how many have been retired the pair every
/// leak claim below is made of.
private(set) var installs = 0
private(set) var removals = 0
private var handler: (@MainActor (NSEvent.ModifierFlags) -> Void)?
private var current: Watch?
/// Whether a monitor is installed right now.
var isWatching: Bool { handler != nil }
func watch(
_ onFlip: @escaping @MainActor (NSEvent.ModifierFlags) -> Void
) -> any ModifierFlipWatch {
installs += 1
handler = onFlip
let watch = Watch(source: self)
current = watch
return watch
}
/// A `.flagsChanged` arriving with this flag state. The event reports the **whole** set rather
/// than a delta, which is what the session's own comparison assumes.
func press(_ flags: NSEvent.ModifierFlags) {
handler?(flags)
}
fileprivate func retire(_ watch: Watch) {
guard current === watch else { return }
removals += 1
handler = nil
current = nil
}
/// Stopping twice is not two removals the session's teardown calls `stop()` on every path
/// without asking whether a watch is installed.
@MainActor
fileprivate final class Watch: ModifierFlipWatch {
private unowned let source: ScriptedFlips
private var stopped = false
init(source: ScriptedFlips) { self.source = source }
func stop() {
guard !stopped else { return }
stopped = true
source.retire(self)
}
}
}
// MARK: - Fixtures
/// One lane with one card enough for a session to pick up, a lane to propose into, and a trash
/// column to refuse.
@MainActor
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 drop context with no window, which is exactly what the flip path can be asked about: every
/// cursor-dependent retarget holds (`globalCursor` answers `nil`), so what is left is the operation,
/// the origin dispatch and the trash's modifier-sensitive refusal the three things a flip decides.
@MainActor
private func makeDrops(
store: BoardStore,
session: DragSession,
registry: LaneDropRegistry
) -> BoardDropContext {
BoardDropContext(
store: store,
session: session,
registry: registry,
gap: 12,
window: { nil },
stripFrame: { .zero },
standard: { 260 }
)
}
@MainActor
private func pickUpCard(_ session: DragSession, from store: BoardStore) {
session.beginCards(
[ItemID(rawValue: Ident.card1)],
folders: [store.rootURL
.appendingPathComponent(Ident.lane1, isDirectory: true)
.appendingPathComponent(Ident.card1, isDirectory: true)],
heights: [44],
container: .board,
source: store
)
}
// MARK: - The watch's lifetime
/// **The monitor cannot outlive the drag**, and the reason it cannot is structural rather than a
/// list of call sites: it is armed at `begin` and stopped at `end`, and `end` is where every way a
/// drag can finish already funnels a delegate's cancel, the button-up belt, the watchdog, the
/// hold's hand-off, the hold's timeout.
@MainActor
@Suite("Modifier flips ▸ the watch's lifetime")
struct ModifierFlipLifetimeTests {
@Test("A drag arms the watch, and ending it retires the monitor")
func armedAtBeginRetiredAtEnd() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let flips = ScriptedFlips()
let session = DragSession(flipSource: flips)
#expect(!flips.isWatching, "an idle session watches nothing")
pickUpCard(session, from: store)
#expect(flips.isWatching)
#expect(flips.installs == 1)
session.end()
#expect(!flips.isWatching)
#expect(flips.removals == 1, "the token must be handed back, or the block fires forever")
}
@Test("A second drag never leaves the first one's monitor installed")
func aSecondBeginRetiresTheFirstWatch() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let flips = ScriptedFlips()
let session = DragSession(flipSource: flips)
pickUpCard(session, from: store)
// `begin` without an intervening `end` the lifecycle trap the watchdog exists for, where a
// finished session's events arrive after the next drag has started.
pickUpCard(session, from: store)
#expect(flips.installs == 2)
#expect(flips.removals == 1, "arming replaces the watch rather than stacking a second one")
#expect(flips.isWatching)
session.end()
#expect(flips.removals == 2)
}
/// The path no delegate can see: a drag macOS never reports the end of. The watchdog polls the
/// physical button and clears the session, and clearing the session is what retires the monitor.
@Test("The watchdog's own teardown retires the monitor")
func theWatchdogRetiresTheWatch() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let flips = ScriptedFlips()
let session = DragSession(flipSource: flips)
pickUpCard(session, from: store)
#expect(flips.isWatching)
// The button is not down in a test bundle, so the watchdog's poll plus its grace period is
// the whole wait. Bounded, so a watchdog that never fires fails rather than hangs.
var cleared = false
for _ in 0..<200 where !cleared {
try? await Task.sleep(for: .milliseconds(10))
cleared = !session.isActive
}
#expect(cleared, "the watchdog is the guaranteed termination path")
#expect(!flips.isWatching)
#expect(flips.removals == 1)
}
/// A Finder file session arms none of this object's own state, so it arms no watch either and
/// it needs none: a file drop is always a copy (`fileDropProposal`), whatever is held down.
@Test("An external file session arms no watch")
func fileSessionsAreNotWatched() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let flips = ScriptedFlips()
let session = DragSession(flipSource: flips)
session.proposeFile(FileDropTarget(
boardRoot: store.rootKey,
landing: .create(laneID: ItemID(rawValue: Ident.lane1), index: 0),
fileCount: 1
))
#expect(!flips.isWatching)
#expect(flips.installs == 0)
session.proposeFile(nil)
}
}
// MARK: - What counts as a flip
/// The nudge itself: one counter, bumped only when something the *operation* is a function of
/// actually moved (`DragLocality.operation` reads and , and nothing else).
@MainActor
@Suite("Modifier flips ▸ the nudge")
struct ModifierFlipNudgeTests {
@Test("Pressing ⌥ and releasing it are two flips")
func pressAndReleaseBothNudge() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let flips = ScriptedFlips()
let session = DragSession(flipSource: flips)
pickUpCard(session, from: store)
let base = session.modifierGeneration
flips.press([.option])
#expect(session.modifierGeneration == base + 1)
flips.press([])
#expect(session.modifierGeneration == base + 2)
}
@Test("A flags event that moves neither ⌥ nor ⌘ is not a flip")
func irrelevantFlagsAreIgnored() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let flips = ScriptedFlips()
let session = DragSession(flipSource: flips)
pickUpCard(session, from: store)
let base = session.modifierGeneration
// has its own meaning in the click grammar and none at all in the drag's operation.
flips.press([.shift])
flips.press([.control])
flips.press([.capsLock])
#expect(session.modifierGeneration == base)
// And *with* one of them along for the ride is still exactly one flip.
flips.press([.shift, .option])
#expect(session.modifierGeneration == base + 1)
flips.press([.control, .option])
#expect(session.modifierGeneration == base + 1, "⌥ never moved")
}
/// The freeze, at its earliest point: a settled release is past retargeting, so a released
/// between the drop and the echo must not even cost a board window a body pass.
@Test("A flip under a committed hold is not a nudge")
func theHoldSwallowsFlips() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let flips = ScriptedFlips()
let session = DragSession(flipSource: flips)
pickUpCard(session, from: store)
session.propose(DropTarget(
boardRoot: store.rootKey,
container: .lane(ItemID(rawValue: Ident.lane1)),
index: 0
))
session.commit(into: store)
let base = session.modifierGeneration
flips.press([.option])
flips.press([])
#expect(session.modifierGeneration == base)
}
@Test("A flip after the drag is over reaches nothing at all")
func aFinishedDragHearsNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let flips = ScriptedFlips()
let session = DragSession(flipSource: flips)
pickUpCard(session, from: store)
session.end()
let base = session.modifierGeneration
flips.press([.option])
#expect(session.modifierGeneration == base, "the monitor is gone; this is the belt")
}
}
// MARK: - The funnel: a flip runs the one shared retarget
/// **The third client of the shared retarget.** A flip resolves nothing of its own: it re-runs the
/// retarget that resolved the standing proposal, at the surface that resolved it, so a keystroke and
/// a mouse sample land in exactly the same place by construction.
@MainActor
@Suite("Modifier flips ▸ the shared retarget")
struct ModifierFlipRetargetTests {
private static let lane1 = ItemID(rawValue: Ident.lane1)
private static let card1 = ItemID(rawValue: Ident.card1)
// MARK: The address a flip replays at
@Test("Each retarget leaves its own address behind, even when it goes on to hold")
func everyRetargetRecordsItsSurface() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let registry = LaneDropRegistry()
let session = DragSession(flipSource: ScriptedFlips())
let drops = makeDrops(store: store, session: session, registry: registry)
#expect(session.retargetOrigin == nil, "a fresh session has nowhere to replay")
pickUpCard(session, from: store)
drops.retargetCards(inLane: Self.lane1)
#expect(session.retargetOrigin?.surface == .lane(Self.lane1))
#expect(session.retargetOrigin?.registry === registry)
drops.retargetTrash(modifiers: [])
#expect(session.retargetOrigin?.surface == .trash)
// The lane level: the strip's own zones.
session.beginLanes(
[Self.lane1],
folders: [store.rootURL.appendingPathComponent(Ident.lane1, isDirectory: true)],
units: [1],
source: store
)
#expect(session.retargetOrigin == nil, "a new drag starts with no address")
drops.retargetLanes()
#expect(session.retargetOrigin?.surface == .strip)
session.end()
#expect(session.retargetOrigin == nil)
}
// MARK: The re-admission at home
/// The confirmed half of the card, end to end: pressed with the mouse perfectly still flips
/// the operation to a copy, and a copy leaves its originals standing in the source board's
/// resting layout (`hiddenMembers`) which used to wait for the next mouse sample.
@Test("A stationary ⌥ re-admits the originals at home")
func aStationaryOptionRedrawsTheSource() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let registry = LaneDropRegistry()
let session = DragSession(flipSource: ScriptedFlips())
let drops = makeDrops(store: store, session: session, registry: registry)
pickUpCard(session, from: store)
drops.retargetCards(inLane: Self.lane1)
#expect(session.operation == .move)
#expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1])
drops.retargetAfterModifierFlip(modifiers: [.option])
#expect(session.operation == .copy)
#expect(session.hiddenMembers(onBoardRooted: store.rootKey).isEmpty,
"a copy's originals stay exactly where they are")
// And releasing lifts them again, with the pointer still untouched.
drops.retargetAfterModifierFlip(modifiers: [])
#expect(session.operation == .move)
#expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1])
}
/// The one carve-out that outranks the modifier: a within-board lane drag is a reorder and is
/// simply ignored there (`DragLocality.operation`). The flip runs; the answer does not move.
@Test("A within-board lane drag ignores the flip, exactly as it ignores the modifier")
func laneDragsIgnoreTheFlip() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let registry = LaneDropRegistry()
let session = DragSession(flipSource: ScriptedFlips())
let drops = makeDrops(store: store, session: session, registry: registry)
session.beginLanes(
[Self.lane1],
folders: [store.rootURL.appendingPathComponent(Ident.lane1, isDirectory: true)],
units: [1],
source: store
)
drops.retargetLanes()
drops.retargetAfterModifierFlip(modifiers: [.option])
#expect(session.operation == .move)
#expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.lane1])
}
// MARK: The operation-dependent refusal
/// **The trash refuses copies** (04-interactions.md The trash): "copying into the trash is not
/// a thing". The gate is a function of the operation, so it is a function of the modifiers and
/// until now it was re-asked only when the mouse moved, which is why a released over the
/// column left it inert under a pointer sitting right on it.
@Test("Releasing ⌥ over the trash proposes the delete, with the pointer perfectly still")
func theTrashReadmitsTheDropOnRelease() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.isTrashVisible = true
let registry = LaneDropRegistry()
let session = DragSession(flipSource: ScriptedFlips())
let drops = makeDrops(store: store, session: session, registry: registry)
pickUpCard(session, from: store)
// The cursor arrives over the column with down: the copy is refused, and the column
// declines to be a target rather than cancelling the drag.
drops.retargetTrash(modifiers: [.option])
#expect(session.operation == .copy)
#expect(session.trashProposal(onBoardRooted: store.rootKey) == nil)
// comes up. No mouse event follows this is the whole point.
drops.retargetAfterModifierFlip(modifiers: [])
#expect(session.operation == .move)
#expect(session.trashProposal(onBoardRooted: store.rootKey) == TrashDrop.landingIndex)
}
// MARK: Rule 2, on the flip's path too
/// The `.lane` replay is the lane delegate's own pair revalidate, then the masonry's zones
/// so a proposal whose lane vanished in a reload withdraws on a flip exactly as it would on the
/// next mouse sample.
@Test("A flip revalidates before it retargets: a vanished lane's proposal withdraws")
func theFlipAppliesRuleTwo() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let registry = LaneDropRegistry()
let session = DragSession(flipSource: ScriptedFlips())
let drops = makeDrops(store: store, session: session, registry: registry)
pickUpCard(session, from: store)
drops.retargetCards(inLane: Self.lane1)
// A proposal naming a lane this board does not have the reload that deleted it landed
// while the pointer sat still.
session.propose(DropTarget(
boardRoot: store.rootKey,
container: .lane(ItemID(rawValue: Ident.lane2)),
index: 0
))
drops.retargetAfterModifierFlip(modifiers: [.option])
#expect(session.proposal == nil, "deleted lanes are never drop targets")
}
// MARK: Who answers
/// The origin names a `LaneDropRegistry`, which is one board **window**'s so the window under
/// the cursor answers a flip and no other does. A root comparison would have a second window on
/// the same board retargeting against a cursor nowhere near its lanes; a cross-board drag would
/// have the *source* board answering for the destination.
@Test("Only the window that resolved the proposal answers the flip")
func anotherWindowDoesNotAnswer() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = DragSession(flipSource: ScriptedFlips())
let hovered = LaneDropRegistry()
let elsewhere = LaneDropRegistry()
let hoveredDrops = makeDrops(store: store, session: session, registry: hovered)
let otherDrops = makeDrops(store: store, session: session, registry: elsewhere)
pickUpCard(session, from: store)
hoveredDrops.retargetCards(inLane: Self.lane1)
otherDrops.retargetAfterModifierFlip(modifiers: [.option])
#expect(session.operation == .move, "this window is not where the proposal lives")
#expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1])
hoveredDrops.retargetAfterModifierFlip(modifiers: [.option])
#expect(session.operation == .copy)
}
@Test("A flip with no address recorded resolves nothing")
func noOriginNoAnswer() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let session = DragSession(flipSource: ScriptedFlips())
let drops = makeDrops(store: store, session: session, registry: LaneDropRegistry())
pickUpCard(session, from: store)
drops.retargetAfterModifierFlip(modifiers: [.option])
#expect(session.operation == .move, "no surface has resolved a proposal yet")
}
// MARK: The freeze
/// The same freeze `propose` and `resolveOperation` wear, asked once more at the funnel: 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.
@Test("A settled release is past retargeting — a flip changes nothing")
func theHoldFreezesTheFlip() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.isTrashVisible = true
let registry = LaneDropRegistry()
let session = DragSession(flipSource: ScriptedFlips())
let drops = makeDrops(store: store, session: session, registry: registry)
pickUpCard(session, from: store)
drops.retargetTrash(modifiers: [])
#expect(session.trashProposal(onBoardRooted: store.rootKey) == TrashDrop.landingIndex)
session.commit(into: store)
drops.retargetAfterModifierFlip(modifiers: [.option])
#expect(session.operation == .move, "the write named the move")
#expect(session.trashProposal(onBoardRooted: store.rootKey) == TrashDrop.landingIndex)
#expect(session.hiddenMembers(onBoardRooted: store.rootKey) == [Self.card1])
}
}