Repository navigation
perf: render Overview row selection on the GPU to fix scroll stutter (#1674) #1740
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,191 @@ | ||
| 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 let 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 = Self.makeSelectedTextTintFilter() | ||
| super.init(frame: .zero) | ||
| self.wantsLayer = true | ||
| 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 | ||
| } | ||
|
|
||
| /// 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 | ||
| // Whiten 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 white while preserving alpha, reproducing the all-white selected | ||
| /// row appearance. Core Image runs this on the GPU (Metal), so it composites for free per frame. | ||
| private static func makeSelectedTextTintFilter() -> CIFilter? { | ||
| guard let filter = CIFilter(name: "CIColorMatrix") else { return nil } | ||
| 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: 1, y: 1, z: 1, w: 0), forKey: "inputBiasVector") | ||
| return filter | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.