4c092ce344
* fix(menu): defer copy-icon click work off the NSMenu tracking loop
On macOS 26.4.1, clicking the doc.on.doc "Copy error" icon inside a hosted
menu card freezes the cursor for several seconds. Two SwiftUI / NSView
handlers run work synchronously inside the live NSMenu tracking event
loop:
- `CopyIconButton.body` (MenuCardView.swift:316) calls
`withAnimation { didCopy = true }` immediately on click and queues a
second `withAnimation { didCopy = false }` 0.9s later. Each
withAnimation inside a tracking-mode hosted view forces a synchronous
SwiftUI hosting layout pass on the main thread that the menu engine
cannot service mid-tracking.
- `ClickToCopyView.mouseDown` (ClickToCopyOverlay.swift) writes to
`NSPasteboard.general` synchronously. Pasteboard writes emit
distributed notifications whose synchronous watchers can re-enter the
menu engine; the tighter main-thread budget on macOS 26 makes this
user-visible.
Both handlers now `DispatchQueue.main.async` their work off the current
tracking tick (so it runs after AppKit unwinds back to a normal mode),
drop `withAnimation` in favour of plain state mutation, and guard
`updateNSView` against no-op writes so a stable parent card re-render
does not invalidate the NSView. The checkmark feedback still works (it
flips on the next tick and reverts after 0.9s) and the UX — clicking
the icon next to the error to copy — is preserved.
Adds `Tests/CodexBarTests/ClickToCopyOverlayTests.swift` covering the
pasteboard sentinel write, `acceptsFirstMouse` behaviour, and
`copyText` storage.
Fixes #1388.
* fix: defer all in-menu copy work
---------
Co-authored-by: Peter Steinberger <steipete@gmail.com>
78 lines
2.2 KiB
Swift
78 lines
2.2 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
@MainActor
|
|
enum MenuPasteboardCopy {
|
|
typealias DeferredAction = @MainActor @Sendable () -> Void
|
|
typealias Scheduler = @MainActor @Sendable (@escaping DeferredAction) -> Void
|
|
typealias Writer = @MainActor @Sendable (String) -> Void
|
|
|
|
static func perform(
|
|
_ text: String,
|
|
scheduler: Scheduler = Self.schedule,
|
|
writer: @escaping Writer = Self.write,
|
|
completion: @escaping DeferredAction = {})
|
|
{
|
|
scheduler {
|
|
writer(text)
|
|
completion()
|
|
}
|
|
}
|
|
|
|
private static func schedule(_ action: @escaping DeferredAction) {
|
|
DispatchQueue.main.async(execute: action)
|
|
}
|
|
|
|
private static func write(_ text: String) {
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(text, forType: .string)
|
|
}
|
|
}
|
|
|
|
struct ClickToCopyOverlay: NSViewRepresentable {
|
|
let copyText: String
|
|
|
|
func makeNSView(context: Context) -> ClickToCopyView {
|
|
ClickToCopyView(copyText: self.copyText)
|
|
}
|
|
|
|
func updateNSView(_ nsView: ClickToCopyView, context: Context) {
|
|
// Guard against no-op writes to avoid AppKit view invalidation on every
|
|
// parent card SwiftUI diff (each MenuCardView body re-eval runs through
|
|
// .overlay { ClickToCopyOverlay(...) }, which calls updateNSView even
|
|
// when copyText is unchanged).
|
|
guard nsView.copyText != self.copyText else { return }
|
|
nsView.copyText = self.copyText
|
|
}
|
|
}
|
|
|
|
final class ClickToCopyView: NSView {
|
|
var copyText: String
|
|
private let copyAction: (String) -> Void
|
|
|
|
init(
|
|
copyText: String,
|
|
copyAction: @escaping (String) -> Void = { MenuPasteboardCopy.perform($0) })
|
|
{
|
|
self.copyText = copyText
|
|
self.copyAction = copyAction
|
|
super.init(frame: .zero)
|
|
self.wantsLayer = false
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
|
|
true
|
|
}
|
|
|
|
override func mouseDown(with event: NSEvent) {
|
|
_ = event
|
|
self.copyAction(self.copyText)
|
|
}
|
|
}
|