310 lines · 12.8 KB · Swift
RawDownload
import AppKitimport SwiftUI /// Hosts a menu-card SwiftUI row whose selection highlight is rendered entirely by AppKit/Core/// Animation instead of SwiftUI, so moving the highlight while scrolling costs no SwiftUI body/// re-evaluation or content re-rasterization.////// The reported Overview scroll stutter comes from driving the native selection look through SwiftUI:/// each scroll step flips `menuItemHighlighted`, which re-renders the entire rich row subtree/// (header, usage bars, storage line). A headless benchmark measured ~3–10 ms per toggle with/// spikes past one 120 Hz frame, matching the dropped frames in the bug report.////// This view keeps the SwiftUI content pinned to its normal (unselected) appearance and recreates/// the selected look in two GPU-composited steps that never touch the SwiftUI graph:///   1. an `NSVisualEffectView` with the native `.selection` material drawn behind the content, and///   2. a `CIColorMatrix` content filter that maps the row's pixels to the selected text color —///      this matches the existing design, where every element already becomes///      `selectedMenuItemTextColor` when highlighted./// Toggling selection then costs a layer property change (~0.05 ms) rather than a SwiftUI pass.@MainActorfinal class GPUSelectionHostingView<Content: View>: NSView, MenuCardHighlighting, MenuCardMeasuring {    private let hosting: NSHostingView<MenuCardSectionContainerView<Content>>    private let selectionView = NSVisualEffectView()    private var tintFilter: CIFilter?    private var isRowHighlighted = false    private var onClick: (() -> Void)?    private let containsInteractiveControls: Bool    private let interactiveRegionStore: MenuCardInteractiveRegionStore?     private(set) var allowsMenuHighlight: Bool     /// Selection inset/radius mirror the SwiftUI `MenuCardSectionContainerView` highlight    /// (`.padding(.horizontal, 6).padding(.vertical, 2)` with a 6 pt corner radius) so the AppKit    /// background lands in the same place the SwiftUI one used to.    private static var selectionHorizontalInset: CGFloat {        6    }     private static var selectionVerticalInset: CGFloat {        2    }     private static var selectionCornerRadius: CGFloat {        6    }     /// Short enough that a fast flick still looks crisp, long enough to read as a glide rather than    /// a hard cut. Tunable from real-device recordings.    private static var selectionFadeDuration: CFTimeInterval {        0.06    }     init(        rootView: MenuCardSectionContainerView<Content>,        allowsMenuHighlight: Bool,        containsInteractiveControls: Bool = false,        interactiveRegionStore: MenuCardInteractiveRegionStore? = nil,        onClick: (() -> Void)?)    {        self.hosting = NSHostingView(rootView: rootView)        self.allowsMenuHighlight = allowsMenuHighlight        self.containsInteractiveControls = containsInteractiveControls        self.interactiveRegionStore = interactiveRegionStore        self.onClick = onClick        self.tintFilter = nil        super.init(frame: .zero)        self.wantsLayer = true        self.refreshTintFilter()        self.setupSelectionView()        self.setupHosting()    }     @available(*, unavailable)    required init?(coder _: NSCoder) {        fatalError("init(coder:) has not been implemented")    }     override var allowsVibrancy: Bool {        true    }     override var intrinsicContentSize: NSSize {        NSSize(width: self.frame.width, height: self.hosting.intrinsicContentSize.height)    }     override func acceptsFirstMouse(for _: NSEvent?) -> Bool {        true    }     override func viewDidChangeEffectiveAppearance() {        super.viewDidChangeEffectiveAppearance()        self.refreshTintFilter()    }     /// Forward accessibility activation to the click handler, mirroring `MenuCardItemHostingView`.    override func accessibilityRole() -> NSAccessibility.Role? {        self.onClick == nil ? super.accessibilityRole() : .button    }     override func accessibilityPerformPress() -> Bool {        guard let onClick = self.onClick else {            return super.accessibilityPerformPress()        }        onClick()        return true    }     override func hitTest(_ point: NSPoint) -> NSView? {        let descendant = super.hitTest(point)        if let descendant {            var current: NSView? = descendant            while let view = current, view !== self {                if view is NSButton || view is NSControl {                    return descendant                }                current = view.superview            }            if self.hitsHostedInteractiveControl(at: point) {                return descendant            }            if descendant !== self, self.onClick != nil {                return self            }        }        return descendant    }     private func hitsHostedInteractiveControl(at point: NSPoint) -> Bool {        guard self.containsInteractiveControls else { return false }        let hostedPoint = self.hosting.convert(point, from: self)        return self.interactiveRegionStore?.contains(            hostedPoint,            hostingBounds: self.hosting.bounds,            fittedSize: self.hosting.fittingSize) == true    }     private func locationInView(for event: NSEvent) -> NSPoint {        guard self.window != nil else {            return event.locationInWindow        }        return self.convert(event.locationInWindow, from: nil)    }     override func mouseDown(with event: NSEvent) {        guard event.type == .leftMouseDown, self.onClick != nil else {            super.mouseDown(with: event)            return        }        guard self.bounds.contains(self.locationInView(for: event)), let window = self.window else { return }         // A submenu-backed NSMenuItem consumes mouseUp in its nested tracking loop before a custom        // view receives it. Track the drag/up sequence directly so release-inside cancellation stays        // native while the menu never gets a chance to close before the row action runs.        var shouldInvoke = false        window.trackEvents(            matching: [.leftMouseDragged, .leftMouseUp],            timeout: NSEvent.foreverDuration,            mode: .eventTracking)        { [weak self] trackedEvent, stop in            guard let self, let trackedEvent else {                stop.pointee = true                return            }            if self.primaryPressShouldYieldToMenu(for: trackedEvent) {                // We dequeued this drag from the window; put it back so NSMenu's tracking loop can                // continue native drag-to-submenu selection from the same event.                window.postEvent(trackedEvent, atStart: true)                stop.pointee = true                return            }            guard let decision = self.primaryPressDecision(for: trackedEvent) else { return }            shouldInvoke = decision            stop.pointee = true        }        if shouldInvoke {            self.onClick?()        }    }     private func primaryPressDecision(for event: NSEvent) -> Bool? {        guard event.type == .leftMouseUp else { return nil }        return self.bounds.contains(self.locationInView(for: event))    }     private func primaryPressShouldYieldToMenu(for event: NSEvent) -> Bool {        event.type == .leftMouseDragged && !self.bounds.contains(self.locationInView(for: event))    }     override func layout() {        super.layout()        self.selectionView.frame = self.bounds.insetBy(            dx: Self.selectionHorizontalInset,            dy: Self.selectionVerticalInset)        self.selectionView.layer?.cornerRadius = Self.selectionCornerRadius        self.hosting.frame = self.bounds    }     func setHighlighted(_ highlighted: Bool) {        guard self.isRowHighlighted != highlighted else { return }        self.isRowHighlighted = highlighted        // Tint the content to the selected text color via a GPU color matrix; clearing the        // filter returns it to its normal palette. No SwiftUI invalidation happens here.        if let tintFilter {            self.hosting.layer?.filters = highlighted ? [tintFilter] : []        }        // Crossfade the selection background instead of hard-cutting it. As the wheel moves the        // highlight, the leaving row fades out while the arriving row fades in, which reads as the        // selection gliding between rows rather than teleporting. The fade is short so fast flicks        // still resolve crisply. Runs entirely on the GPU via Core Animation.        let layer = self.selectionView.layer        let fade = CABasicAnimation(keyPath: "opacity")        fade.fromValue = layer?.presentation()?.opacity ?? (highlighted ? 0 : 1)        fade.toValue = highlighted ? 1 : 0        fade.duration = Self.selectionFadeDuration        fade.timingFunction = CAMediaTimingFunction(name: .easeOut)        layer?.add(fade, forKey: "selectionFade")        layer?.opacity = highlighted ? 1 : 0    }     func measuredHeight(width: CGFloat) -> CGFloat {        self.hosting.frame = NSRect(origin: self.hosting.frame.origin, size: NSSize(width: width, height: 1))        self.hosting.layoutSubtreeIfNeeded()        return self.hosting.fittingSize.height    }     #if DEBUG    /// True once the menu marks this row highlighted via `setHighlighted`.    var isHighlightedForTesting: Bool {        self.isRowHighlighted    }     /// The hosted SwiftUI highlight state, which must stay `false` for GPU-selected rows — proving    /// selection never re-invalidates the SwiftUI graph while scrolling.    var swiftUIHighlightStateIsHighlightedForTesting: Bool {        self.hosting.rootView.highlightState.isHighlighted    }    #endif     private func setupSelectionView() {        self.selectionView.material = .selection        self.selectionView.blendingMode = .withinWindow        self.selectionView.state = .active        self.selectionView.isEmphasized = true        self.selectionView.wantsLayer = true        self.selectionView.layer?.masksToBounds = true        // Visibility is driven by layer opacity (crossfaded in `setHighlighted`) rather than        // `isHidden`, so the selection can glide in and out instead of hard-cutting.        self.selectionView.layer?.opacity = 0        self.selectionView.autoresizingMask = [.width, .height]        self.addSubview(self.selectionView)    }     private func setupHosting() {        self.hosting.wantsLayer = true        self.hosting.autoresizingMask = [.width, .height]        self.addSubview(self.hosting)    }     /// Maps every pixel's RGB to the system selected-menu-item text color while preserving alpha,    /// reproducing the appearance the SwiftUI rows already adopt when highlighted. The bias is read    /// from `NSColor.selectedMenuItemTextColor` rather than hard-coded to white so graphite/    /// high-contrast/accessibility appearances tint correctly. Core Image runs this on the GPU    /// (Metal), so it composites for free per frame.    private func refreshTintFilter() {        self.tintFilter = Self.makeSelectedTextTintFilter(appearance: self.effectiveAppearance)        if self.isRowHighlighted {            self.hosting.layer?.filters = self.tintFilter.map { [$0] } ?? []        }    }     private static func makeSelectedTextTintFilter(appearance: NSAppearance) -> CIFilter? {        guard let filter = CIFilter(name: "CIColorMatrix") else { return nil }        var tint: NSColor = .white        appearance.performAsCurrentDrawingAppearance {            tint = NSColor.selectedMenuItemTextColor.usingColorSpace(.deviceRGB) ?? .white        }        filter.setValue(CIVector(x: 0, y: 0, z: 0, w: 0), forKey: "inputRVector")        filter.setValue(CIVector(x: 0, y: 0, z: 0, w: 0), forKey: "inputGVector")        filter.setValue(CIVector(x: 0, y: 0, z: 0, w: 0), forKey: "inputBVector")        filter.setValue(CIVector(x: 0, y: 0, z: 0, w: 1), forKey: "inputAVector")        filter.setValue(            CIVector(x: tint.redComponent, y: tint.greenComponent, z: tint.blueComponent, w: 0),            forKey: "inputBiasVector")        return filter    }} #if DEBUGextension GPUSelectionHostingView {    func _test_hitsHostedInteractiveControl(at point: NSPoint) -> Bool {        self.hitsHostedInteractiveControl(at: point)    }     func _test_simulateRuntimeClick(at point: NSPoint? = nil) -> Bool {        let clickPoint = point ?? NSPoint(x: self.bounds.midX, y: self.bounds.midY)        guard let onClick = self.onClick, self.hitTest(clickPoint) === self else { return false }        guard self.bounds.contains(clickPoint) else { return false }        onClick()        return true    }     func _test_primaryPressDecision(for event: NSEvent) -> Bool? {        self.primaryPressDecision(for: event)    }     func _test_primaryPressShouldYieldToMenu(for event: NSEvent) -> Bool {        self.primaryPressShouldYieldToMenu(for: event)    }}#endif