The pasteboard re-reads when ⌘ goes down — a screenshot no longer leaves ⌘V dead until the next app switch
Commands validate by conditional responder attachment, so the cached imagePayload decided whether ⌘V had a responder at all — and the one pasteboard writer that never deactivates the app, the screenshot hotkey, changed the pasteboard without any checkpoint firing. Menu validation is now two concrete moments: a menu beginning to track, and ⌘ going down, whose beat before the letter is when the observation re-arms the responder. One changeCount read per check, no timers. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -48,10 +48,20 @@ import os
|
||||
///
|
||||
/// `NSPasteboard.changeCount` is a machine-wide counter, so a value that moved without this store
|
||||
/// moving it means another app owns the pasteboard now. It is checked exactly where 04 says — menu
|
||||
/// validation (which reads the cached `payload`), app activation, and before every paste — and
|
||||
/// nowhere else. `payload` is observable state rather than a computed pasteboard read precisely so
|
||||
/// the menu items' enablement re-evaluates when it changes rather than whenever SwiftUI happens to
|
||||
/// rebuild them.
|
||||
/// validation, app activation, and before every paste — and nowhere else. `payload` is observable
|
||||
/// state rather than a computed pasteboard read precisely so the menu items' enablement re-evaluates
|
||||
/// when it changes rather than whenever SwiftUI happens to rebuild them.
|
||||
///
|
||||
/// **"Menu validation" is two checkpoints here, not a hook**: the commands validate by conditional
|
||||
/// responder attachment (`ClipboardCommands` — availability *is* the handler's presence), which
|
||||
/// reads the cached observables and offers AppKit no validation-time callback. So the cache is
|
||||
/// re-read at the two moments a command could be about to fire: a menu beginning to track (the
|
||||
/// mouse's path), and **⌘ going down** (the key equivalent's path — the modifier lands a beat
|
||||
/// before its letter, and the observation's re-render re-arms the responder inside that beat).
|
||||
/// Without the second checkpoint the one pasteboard writer that never deactivates this app — the
|
||||
/// screenshot hotkey, ⌃⇧⌘4 — would leave ⌘V dead until the next app switch, which is the image
|
||||
/// branch's headline gesture failing in the exact case it was built for. Both checkpoints are one
|
||||
/// `changeCount` read in the common case, which is why they can afford to fire on every ⌘-chord.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class ClipboardStore {
|
||||
@@ -111,11 +121,16 @@ public final class ClipboardStore {
|
||||
/// than a race nobody has lost yet.
|
||||
@ObservationIgnored private var stagingChain: Task<Void, Never>?
|
||||
|
||||
/// `nonisolated(unsafe)` for one reason and one only: `deinit` is nonisolated and this is the
|
||||
/// token it has to hand back. It is written exactly once, in `init` on the main actor, and read
|
||||
/// `nonisolated(unsafe)` for one reason and one only: `deinit` is nonisolated and these are the
|
||||
/// tokens it has to hand back. Written exactly once, in `init` on the main actor, and read
|
||||
/// exactly once, in `deinit` after the last reference is gone — there is no window in which two
|
||||
/// contexts could touch it.
|
||||
@ObservationIgnored private nonisolated(unsafe) var activationObserver: (any NSObjectProtocol)?
|
||||
/// contexts could touch them.
|
||||
@ObservationIgnored private nonisolated(unsafe) var stalenessObservers: [any NSObjectProtocol] = []
|
||||
|
||||
/// The ⌘-down checkpoint's monitor token (see the type comment's takeover section) — same
|
||||
/// lifetime story as `stalenessObservers`, and `LocalModifierFlipWatch`'s warning applies: a
|
||||
/// token dropped on the floor is a block that keeps firing for the rest of the process.
|
||||
@ObservationIgnored private nonisolated(unsafe) var commandKeyToken: Any?
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "clipboard")
|
||||
|
||||
@@ -148,7 +163,7 @@ public final class ClipboardStore {
|
||||
// Returning to the foreground is when another app's copy becomes this app's problem: the
|
||||
// cached payload is re-read, a cut whose pasteboard entry is gone is voided and undimmed, and
|
||||
// the staged tree that can never be pasted again goes now rather than lingering.
|
||||
activationObserver = NotificationCenter.default.addObserver(
|
||||
stalenessObservers.append(NotificationCenter.default.addObserver(
|
||||
forName: NSApplication.didBecomeActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
@@ -158,12 +173,34 @@ public final class ClipboardStore {
|
||||
self.refresh()
|
||||
self.sweep()
|
||||
}
|
||||
})
|
||||
// The two validation checkpoints (type comment ▸ takeover): a menu beginning to track, and
|
||||
// ⌘ going down. Refresh only — the activation sweep is about reclaiming staged trees, and a
|
||||
// ⌘-chord is not the moment to enqueue disk work on the off chance the pasteboard moved.
|
||||
stalenessObservers.append(NotificationCenter.default.addObserver(
|
||||
forName: NSMenu.didBeginTrackingNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.refresh() }
|
||||
})
|
||||
commandKeyToken = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
||||
// Local monitors run on the main thread, before the event reaches its window
|
||||
// (`LocalModifierFlipWatch`'s note) — and the event is returned unchanged, always, for
|
||||
// its reason too: ⌘ means things to the rest of the app.
|
||||
if event.modifierFlags.contains(.command) {
|
||||
MainActor.assumeIsolated { self?.refresh() }
|
||||
}
|
||||
return event
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let activationObserver {
|
||||
NotificationCenter.default.removeObserver(activationObserver)
|
||||
for observer in stalenessObservers {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
if let commandKeyToken {
|
||||
NSEvent.removeMonitor(commandKeyToken)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,9 +658,10 @@ public final class ClipboardStore {
|
||||
|
||||
/// Re-reads the pasteboard **if and only if it has changed**, and voids a cut the change orphaned.
|
||||
///
|
||||
/// One `changeCount` read in the common case, which is what makes it cheap enough for the three
|
||||
/// callers 04 names: menu validation (through the cached `payload`), app activation, and the
|
||||
/// front of every paste.
|
||||
/// One `changeCount` read in the common case, which is what makes it cheap enough for every
|
||||
/// caller 04 names: app activation, the front of every paste, and menu validation's two
|
||||
/// checkpoints — a menu beginning to track, and ⌘ going down (the type comment's takeover
|
||||
/// section).
|
||||
public func refresh() {
|
||||
let count = pasteboard.changeCount
|
||||
guard count != lastChangeCount else { return }
|
||||
|
||||
Reference in New Issue
Block a user