Files
steipete--codexbar/Sources/CodexBar/MenuCardGPUSelectionView.swift
T
2026-07-13 12:22:33 +08:00

311 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import 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 ~310 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.
@MainActor
final 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 DEBUG
extension 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