Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 213 additions & 0 deletions Sources/CodexBar/MenuCardGPUSelectionView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
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 ~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.
@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(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
}
}
1 change: 1 addition & 0 deletions Sources/CodexBar/StatusItemController+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,7 @@ extension StatusItemController {
section: "overview",
additional: [UsageMenuCardView.Model.heightFingerprintField("storage", storageText)]),
submenu: submenu,
usesGPUSelection: true,
onClick: { [weak self, weak interactionMenu] in
guard let self, let interactionMenu else { return }
self.selectOverviewProvider(row.provider, menu: interactionMenu)
Expand Down
49 changes: 48 additions & 1 deletion Sources/CodexBar/StatusItemController+MenuCardItems.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ extension StatusItemController {
submenuIndicatorAlignment: Alignment = .topTrailing,
submenuIndicatorTopPadding: CGFloat = 8,
containsInteractiveControls: Bool = false,
usesGPUSelection: Bool = false,
onClick: (() -> Void)? = nil) -> NSMenuItem
{
let allowsMenuHighlight = submenu != nil || onClick != nil
Expand All @@ -48,6 +49,39 @@ extension StatusItemController {
return item
}

if usesGPUSelection {
// Selection is painted by AppKit/GPU, so the SwiftUI content is pinned to its normal
// appearance via a `highlightState` that is never flipped; these rows skip hosting-view
// recycling because the recycler is typed to `MenuCardItemHostingView`.
let wrapped = MenuCardSectionContainerView(
highlightState: MenuCardHighlightState(),
showsSubmenuIndicator: submenu != nil,
submenuIndicatorAlignment: submenuIndicatorAlignment,
submenuIndicatorTopPadding: submenuIndicatorTopPadding,
refreshMonitor: self.menuCardRefreshMonitor)
{
view
}
let gpuHosting = GPUSelectionHostingView(
rootView: wrapped,
allowsMenuHighlight: allowsMenuHighlight,
onClick: onClick)
let gpuHeight = self.cachedMenuCardHeight(
for: id,
scope: heightCacheScope ?? id,
width: width,
fingerprint: heightCacheFingerprint)
{
self.menuCardHeight(for: gpuHosting, width: width)
}
gpuHosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: gpuHeight))
return self.makeMenuCardNSMenuItem(
hosting: gpuHosting,
id: id,
submenu: submenu,
isEnabled: allowsMenuHighlight || containsInteractiveControls)
}

let hosting: MenuCardItemHostingView<MenuCardSectionContainerView<CardContent>>
if let recycled = self.takeRecyclableMenuCardView(
for: id,
Expand Down Expand Up @@ -93,10 +127,23 @@ extension StatusItemController {
self.menuCardHeight(for: hosting, width: width)
}
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height))
return self.makeMenuCardNSMenuItem(
hosting: hosting,
id: id,
submenu: submenu,
isEnabled: allowsMenuHighlight || containsInteractiveControls)
}

/// Wraps a measured hosting view in the `NSMenuItem` the menu installs, wiring submenu routing.
private func makeMenuCardNSMenuItem(
hosting: NSView,
id: String,
submenu: NSMenu?,
isEnabled: Bool) -> NSMenuItem
{
let item = NSMenuItem()
item.view = hosting
item.isEnabled = allowsMenuHighlight || containsInteractiveControls
item.isEnabled = isEnabled
item.representedObject = id
item.submenu = submenu
if submenu != nil {
Expand Down
21 changes: 11 additions & 10 deletions Sources/CodexBar/StatusItemController+OverviewScroll.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,14 @@ enum OverviewScrollStep {
}

extension StatusItemController {
/// Pixel distance per highlight step for trackpads and other precise devices.
private static let preciseScrollStepThreshold: CGFloat = 24
/// Line distance per highlight step for classic scroll wheels.
private static let lineScrollStepThreshold: CGFloat = 0.9
/// A single fast flick should not race the highlight through the whole list.
private static let maxScrollStepsPerEvent = 3

/// Scrolling the wheel while the overview tab is open moves the row highlight up/down.
/// Steps are delivered as mouse-move events over the custom card views so AppKit's
/// native menu highlight and submenu behavior stay intact.
/// Classic scroll wheels keep row-to-row overview navigation. Precise trackpad scrolling is
/// left to AppKit's native menu scroller so the content follows the user's fingers instead
/// of waiting for a threshold and jumping the highlighted row.
@discardableResult
func handleOverviewScrollWheel(_ event: NSEvent, menu: NSMenu) -> Bool {
guard self.menuHasOverviewRows(menu) else {
Expand All @@ -28,8 +26,13 @@ extension StatusItemController {
self.overviewScrollAccumulatedDelta = 0
return false
}
// Momentum-phase events after a flick would keep moving the highlight long after
// the fingers left the trackpad; swallow them without stepping.
guard !event.hasPreciseScrollingDeltas else {
self.overviewScrollAccumulatedDelta = 0
return false
}
// Precise trackpad/Magic Mouse scrolling already returned above, so this only guards
// non-precise devices that still report a momentum phase: swallow that flick tail so the
// highlight does not keep stepping after the fingers lift.
guard event.momentumPhase.isEmpty else { return true }
let delta = event.scrollingDeltaY
guard delta != 0 else { return false }
Expand All @@ -41,9 +44,7 @@ extension StatusItemController {
}
self.overviewScrollAccumulatedDelta += delta

let threshold = event.hasPreciseScrollingDeltas
? Self.preciseScrollStepThreshold
: Self.lineScrollStepThreshold
let threshold = Self.lineScrollStepThreshold
var steps = 0
while abs(self.overviewScrollAccumulatedDelta) >= threshold, steps < Self.maxScrollStepsPerEvent {
let movingUp = self.overviewScrollAccumulatedDelta > 0
Expand Down
39 changes: 39 additions & 0 deletions Tests/CodexBarTests/MenuCardViewRecyclingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -713,4 +713,43 @@ extension StatusMenuTests {
// The incompatible pool entry is consumed rather than left behind.
#expect(controller.menuCardViewRecyclePool.isEmpty)
}

@Test
func `gpu selection highlight bypasses swiftui highlight state`() {
StatusItemController.setMenuRefreshEnabledForTesting(false)
let previousRendering = StatusItemController.menuCardRenderingEnabled
StatusItemController.menuCardRenderingEnabled = true
defer { StatusItemController.menuCardRenderingEnabled = previousRendering }

let settings = self.makeSettings()
settings.statusChecksEnabled = false
let controller = self.makeRecyclingController(settings: settings)
defer { controller.releaseStatusItemsForTesting() }

let menu = NSMenu()
let item = controller.makeMenuCardItem(
Text("Overview row"),
id: "overview-gpu",
width: 300,
submenu: NSMenu(),
usesGPUSelection: true,
onClick: {})
menu.addItem(item)

guard let gpuView = item.view as? GPUSelectionHostingView<Text>
else {
Issue.record("expected a GPU selection hosting view")
return
}

// The menu highlights the AppKit row, but the hosted SwiftUI highlight state must stay false
// so selection never re-invalidates the SwiftUI graph.
controller.menu(menu, willHighlight: item)
#expect(gpuView.isHighlightedForTesting)
#expect(!gpuView.swiftUIHighlightStateIsHighlightedForTesting)

controller.menu(menu, willHighlight: nil)
#expect(!gpuView.isHighlightedForTesting)
#expect(!gpuView.swiftUIHighlightStateIsHighlightedForTesting)
}
}
Loading