213 lines · 9.0 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(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,        onClick: (() -> Void)?)    {        self.hosting = NSHostingView(rootView: rootView)        self.allowsMenuHighlight = allowsMenuHighlight        self.onClick = onClick        self.tintFilter = nil        super.init(frame: .zero)        self.wantsLayer = true        self.refreshTintFilter()        self.setupSelectionView()        self.setupHosting()        if onClick != nil {            self.installClickRecognizer()        }    }     @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 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)    }     private func installClickRecognizer() {        let recognizer = NSClickGestureRecognizer(target: self, action: #selector(self.handlePrimaryClick(_:)))        recognizer.buttonMask = 0x1        self.addGestureRecognizer(recognizer)    }     @objc private func handlePrimaryClick(_ recognizer: NSClickGestureRecognizer) {        guard recognizer.state == .ended else { return }        self.onClick?()    }     /// 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    }}