From b6d032702f28d4d0af032e212ce00868b260a19a Mon Sep 17 00:00:00 2001 From: hhh2210 Date: Wed, 24 Jun 2026 22:16:22 +0800 Subject: [PATCH 1/3] perf: render Overview row selection on the GPU to fix scroll stutter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overview rows are rich SwiftUI views hosted in NSMenu. Every scroll step flipped `menuItemHighlighted`, which re-rasterized the whole row subtree (~2.4 ms avg, ~25 ms spikes — matching the dropped frames reported in #1674). A headless benchmark over the real hosting path measured A: SwiftUI recolor avg 2.35 ms / max 7.29 ms; `.drawingGroup()` (Metal rasterization only) helped ~28% and still missed the 8.3 ms/120 Hz budget. Move selection rendering off the SwiftUI graph entirely: - AppKit NSVisualEffectView(.selection) background, crossfaded via Core Animation - a CIColorMatrix content filter tinting the row to the selected text color, which Core Image runs on the GPU (Metal) and matches the existing all-white selected look Per-toggle cost drops to ~0.044 ms / max 1.39 ms — 53x faster, well within the 120 Hz budget. Scoped to Overview rows via `makeMenuCardItem(usesGPUSelection:)`; it does not override hitTest, so embedded controls keep working. Also pass precise trackpad scrolls through to AppKit's native menu scroller instead of thresholded row-highlight jumps, so the menu follows the finger; classic notched wheels keep row-to-row navigation. (Trackpad pass-through and the deterministic bypass test came from a parallel Codex exploration.) Adds `gpu selection highlight bypasses swiftui highlight state`, a timing-free test proving the GPU selection never invalidates the hosted SwiftUI highlight state. Co-Authored-By: Claude Opus 4.8 --- .../CodexBar/MenuCardGPUSelectionView.swift | 191 +++++++ .../CodexBar/StatusItemController+Menu.swift | 1 + .../StatusItemController+MenuCardItems.swift | 49 +- .../StatusItemController+OverviewScroll.swift | 16 +- .../MenuCardViewRecyclingTests.swift | 39 ++ .../StatusMenuOverviewScrollTests.swift | 51 +- docs/overview-scroll-stutter-investigation.md | 531 ++++++++++++++++++ 7 files changed, 837 insertions(+), 41 deletions(-) create mode 100644 Sources/CodexBar/MenuCardGPUSelectionView.swift create mode 100644 docs/overview-scroll-stutter-investigation.md diff --git a/Sources/CodexBar/MenuCardGPUSelectionView.swift b/Sources/CodexBar/MenuCardGPUSelectionView.swift new file mode 100644 index 0000000000..de5acd2afa --- /dev/null +++ b/Sources/CodexBar/MenuCardGPUSelectionView.swift @@ -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: NSView, MenuCardHighlighting, MenuCardMeasuring { + private let hosting: NSHostingView> + 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, + 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 + } +} diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index b49726a537..ba2bafa6ad 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -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) diff --git a/Sources/CodexBar/StatusItemController+MenuCardItems.swift b/Sources/CodexBar/StatusItemController+MenuCardItems.swift index 9af56da765..860d2c8875 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardItems.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardItems.swift @@ -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 @@ -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> if let recycled = self.takeRecyclableMenuCardView( for: id, @@ -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 { diff --git a/Sources/CodexBar/StatusItemController+OverviewScroll.swift b/Sources/CodexBar/StatusItemController+OverviewScroll.swift index b8ab54281c..baf8527e5f 100644 --- a/Sources/CodexBar/StatusItemController+OverviewScroll.swift +++ b/Sources/CodexBar/StatusItemController+OverviewScroll.swift @@ -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 { @@ -28,6 +26,10 @@ extension StatusItemController { self.overviewScrollAccumulatedDelta = 0 return false } + guard !event.hasPreciseScrollingDeltas else { + 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.momentumPhase.isEmpty else { return true } @@ -41,9 +43,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 diff --git a/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift b/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift index 70e30e4978..b8fc0bd184 100644 --- a/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift +++ b/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift @@ -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 + 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) + } } diff --git a/Tests/CodexBarTests/StatusMenuOverviewScrollTests.swift b/Tests/CodexBarTests/StatusMenuOverviewScrollTests.swift index 320cc1222c..2a4b7b2fde 100644 --- a/Tests/CodexBarTests/StatusMenuOverviewScrollTests.swift +++ b/Tests/CodexBarTests/StatusMenuOverviewScrollTests.swift @@ -52,7 +52,7 @@ struct StatusMenuOverviewScrollTests { } @Test - func `scroll steps move highlight and respect direction`() throws { + func `coarse wheel steps move highlight and respect direction`() throws { let controller = self.makeController(suiteName: "OverviewScroll-Direction") defer { controller.releaseStatusItemsForTesting() } let menu = self.makeOverviewMenu() @@ -60,12 +60,12 @@ struct StatusMenuOverviewScrollTests { var steps: [OverviewScrollStep] = [] controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) } - let scrollUp = try #require(self.makeScrollEvent(deltaY: 30, precise: true)) + let scrollUp = try #require(self.makeScrollEvent(deltaY: 1, precise: false)) #expect(controller.handleOverviewScrollWheel(scrollUp, menu: menu)) #expect(steps == [.up]) steps = [] - let scrollDown = try #require(self.makeScrollEvent(deltaY: -30, precise: true)) + let scrollDown = try #require(self.makeScrollEvent(deltaY: -1, precise: false)) #expect(controller.handleOverviewScrollWheel(scrollDown, menu: menu)) #expect(steps == [.down]) } @@ -96,41 +96,33 @@ struct StatusMenuOverviewScrollTests { } @Test - func `small precise deltas accumulate before stepping`() throws { - let controller = self.makeController(suiteName: "OverviewScroll-Accumulate") + func `precise trackpad scrolling is passed through to native menu scrolling`() throws { + let controller = self.makeController(suiteName: "OverviewScroll-Precise") defer { controller.releaseStatusItemsForTesting() } let menu = self.makeOverviewMenu() var steps: [OverviewScrollStep] = [] controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) } - let smallScroll = try #require(self.makeScrollEvent(deltaY: 10, precise: true)) - #expect(controller.handleOverviewScrollWheel(smallScroll, menu: menu)) - #expect(steps.isEmpty) - #expect(controller.handleOverviewScrollWheel(smallScroll, menu: menu)) + let scroll = try #require(self.makeScrollEvent(deltaY: 30, precise: true)) + #expect(!controller.handleOverviewScrollWheel(scroll, menu: menu)) #expect(steps.isEmpty) - #expect(controller.handleOverviewScrollWheel(smallScroll, menu: menu)) - #expect(steps == [.up]) } @Test - func `direction change resets accumulated distance`() throws { - let controller = self.makeController(suiteName: "OverviewScroll-Flip") + func `precise trackpad scrolling clears wheel accumulation`() throws { + let controller = self.makeController(suiteName: "OverviewScroll-PreciseReset") defer { controller.releaseStatusItemsForTesting() } let menu = self.makeOverviewMenu() var steps: [OverviewScrollStep] = [] controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) } - let upBelowThreshold = try #require(self.makeScrollEvent(deltaY: 20, precise: true)) - #expect(controller.handleOverviewScrollWheel(upBelowThreshold, menu: menu)) - #expect(steps.isEmpty) - - let downBelowThreshold = try #require(self.makeScrollEvent(deltaY: -20, precise: true)) - #expect(controller.handleOverviewScrollWheel(downBelowThreshold, menu: menu)) + controller.overviewScrollAccumulatedDelta = 0.5 + let scroll = try #require(self.makeScrollEvent(deltaY: 30, precise: true)) + #expect(!controller.handleOverviewScrollWheel(scroll, menu: menu)) #expect(steps.isEmpty) - #expect(controller.handleOverviewScrollWheel(downBelowThreshold, menu: menu)) - #expect(steps == [.down]) + #expect(controller.overviewScrollAccumulatedDelta == 0) } @Test @@ -156,14 +148,14 @@ struct StatusMenuOverviewScrollTests { var steps: [OverviewScrollStep] = [] controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) } - let flick = try #require(self.makeScrollEvent(deltaY: 500, precise: true)) + let flick = try #require(self.makeScrollEvent(deltaY: 500, precise: false)) #expect(controller.handleOverviewScrollWheel(flick, menu: menu)) #expect(steps == [.up, .up, .up]) } @Test - func `capped flick discards leftover distance`() throws { - let controller = self.makeController(suiteName: "OverviewScroll-CapRemainder") + func `precise flick is passed through instead of being capped into highlight jumps`() throws { + let controller = self.makeController(suiteName: "OverviewScroll-PreciseFlick") defer { controller.releaseStatusItemsForTesting() } let menu = self.makeOverviewMenu() @@ -171,12 +163,7 @@ struct StatusMenuOverviewScrollTests { controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) } let flick = try #require(self.makeScrollEvent(deltaY: 500, precise: true)) - #expect(controller.handleOverviewScrollWheel(flick, menu: menu)) - #expect(steps == [.up, .up, .up]) - - steps = [] - let smallScroll = try #require(self.makeScrollEvent(deltaY: 10, precise: true)) - #expect(controller.handleOverviewScrollWheel(smallScroll, menu: menu)) + #expect(!controller.handleOverviewScrollWheel(flick, menu: menu)) #expect(steps.isEmpty) } @@ -193,7 +180,7 @@ struct StatusMenuOverviewScrollTests { var steps: [OverviewScrollStep] = [] controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) } - let scroll = try #require(self.makeScrollEvent(deltaY: 30, precise: true)) + let scroll = try #require(self.makeScrollEvent(deltaY: 1, precise: false)) #expect(!controller.handleOverviewScrollWheel(scroll, menu: menu)) #expect(steps.isEmpty) } @@ -208,7 +195,7 @@ struct StatusMenuOverviewScrollTests { var steps: [OverviewScrollStep] = [] controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) } - let scroll = try #require(self.makeScrollEvent(deltaY: 30, precise: true)) + let scroll = try #require(self.makeScrollEvent(deltaY: 1, precise: false)) #expect(!controller.handleOverviewScrollWheel(scroll, menu: menu)) #expect(steps.isEmpty) #expect(!menu.items.isEmpty) diff --git a/docs/overview-scroll-stutter-investigation.md b/docs/overview-scroll-stutter-investigation.md new file mode 100644 index 0000000000..d32da66198 --- /dev/null +++ b/docs/overview-scroll-stutter-investigation.md @@ -0,0 +1,531 @@ +# Overview Scroll Stutter Investigation + +Status: draft context for external review +Date: 2026-06-20 +Related issue: https://github.com/steipete/CodexBar/issues/1674 +Candidate PRs: + +- Lite row: https://github.com/steipete/CodexBar/pull/1675 +- Rich row: https://github.com/steipete/CodexBar/pull/1676 + +## Purpose + +This document explains the motivation, evidence, design split, current PR state, and open review questions for the CodexBar Overview menu scroll-stutter work. It is written as a handoff document for a second reviewer, especially Claude, to review both the reasoning and the patch directions without needing to reconstruct the whole GitHub thread. + +The core question is not only "does either patch compile?" The real question is whether we chose the right boundary for reducing scroll-time work inside an `NSMenu` that hosts rich SwiftUI rows. + +## Original Problem + +The user-reported symptom is severe scroll jank in the CodexBar home / Overview menu. The problem appears on recent CodexBar builds and on multiple remote latest versions, not just one local install. + +Observed environment from the local sample: + +```text +CodexBar 0.37.0 (90) +macOS 27.0 (26A5353q) +Main-thread sample duration: 8 seconds, 1 ms interval +Main-thread sample count: 3440 +Physical footprint: 211.4M, peak 255.3M +``` + +The user described the behavior as "每次滚动的时候都会卡" while the Overview tab has multiple providers enabled. The screenshot shows the Overview tab with rich provider cards, usage bars, quota sections, cost/token history, and multiple provider entries below. + +## Evidence From The Sample + +The redacted sample summary points at menu tracking and SwiftUI row rendering/layout, not provider refresh or token-cost scanning. + +Relevant sample counts: + +```text +2222 -[NSMenuTrackingSession startRunningMenuEventLoop:] +91 -[NSContextMenuImpl _reloadData] +19 -[NSContextMenuImpl _menuBackingViewDidChangeIntrinsicSizeWithAnimation:] +23 ViewGraphRootValueUpdater.render +12 NSHostingView.hitTest +9 LazyVGridLayout lengthAndSpacing +6 -[NSView scrollWheel:] +``` + +The important interpretation is that scroll input seems to stay inside the `NSMenuTrackingSession` hot path, where AppKit repeatedly re-enters hosted SwiftUI row layout/render/hit-test work. That fits the visible symptom: jank occurs on every scroll event, even without a provider refresh being initiated. + +Two caveats a reviewer should keep in mind about how strong this sample is: + +- The `2222 startRunningMenuEventLoop` frame is the **parent** frame of all menu-tracking activity (including idle waiting), not a leaf hotspot. The meaningful work is in the much smaller leaf counts (`_reloadData 91`, `render 23`, `hitTest 12`, `LazyVGridLayout 9`). Relative to 3440 total samples, the main thread is not pegged continuously, so the jank is more consistent with **bursty per-scroll-event frame hitches** than sustained CPU saturation. This is suggestive, not conclusive — an Instruments time-profiler trace correlating scroll events with dropped frames would be the stronger proof (see "What Is Not Proven"). + +- Reading `StatusItemController+OverviewScroll.swift` makes the likely mechanism concrete. `postOverviewScrollNavigation` does two things per highlight step: it calls `self.menu(menu, willHighlight: target)` to advance highlight state immediately, **and then** posts a synthetic `.mouseMoved` event over the target row's center. On `main`, the highlight-state flip re-renders the full SwiftUI row through `MenuCardSectionContainerView` (`.environment(\.menuItemHighlighted)` + `.foregroundStyle(primary(highlighted))` + a conditional background), and the synthetic mouse-move makes AppKit re-run hit-testing down into the hosted SwiftUI tree (`NSHostingView.hitTest`). So each scroll step costs roughly **two full rich-row re-renders (old + new highlight) plus a hit-test descent**, and a single flick emits up to three steps. That double-re-render-per-step is the most plausible source of the stutter, and it is exactly the link both PRs cut at different points. + +## Source Mapping + +The sample stack maps cleanly onto current `origin/main` around commit `8c4bdd63f3d6d1432fcdb50add7ed6988a2b5734`. + +Key source paths: + +- `Sources/CodexBar/StatusItemController+Menu.swift` + - `addOverviewRows` builds each Overview provider row as a custom menu card. + - It installs provider-detail submenus and click handling. +- `Sources/CodexBar/StatusItemController+MenuTypes.swift` + - `OverviewMenuCardRowView` is the SwiftUI view rendered inside each Overview row. + - It subscribes to menu highlight environment. +- `Sources/CodexBar/MenuCardView.swift` + - `UsageMenuCardUsageSectionView` renders usage content. + - It resolves live menu-card models through `MenuCardRefreshMonitor`. +- `Sources/CodexBar/InlineUsageDashboardContent.swift` + - Uses `LazyVGrid` and mini chart/bar content. + - This matches the `LazyVGridLayout lengthAndSpacing` sample fragment. +- `Sources/CodexBar/StatusItemController+OverviewScroll.swift` + - Handles scroll-wheel navigation on Overview rows. + - Moves highlight by calling menu highlight paths and posting synthetic mouse movement over row views. + +This led to the working hypothesis: + +> The Overview tab keeps several rich SwiftUI provider cards inside an `NSMenu`; scroll/highlight/hit-test re-enters layout and rendering for those hosted rows. The hot path is row presentation and menu tracking, not provider data fetching. + +## Issue Work + +We opened issue #1674: + +https://github.com/steipete/CodexBar/issues/1674 + +Title: + +```text +v0.37.0: Overview menu stutters on every scroll event with multiple providers on macOS 27 +``` + +The issue includes: + +- Repro environment. +- Sample summary. +- Source mapping from sample stack to current main. +- Two proposed fix directions. +- Explicit note that the full sample file was not uploaded because it includes local machine paths. + +We also cc'd two earlier participants, `@Astro-Han` and `@elkaix`, in a comment on #1674 because both had described detailed menu lag in earlier issues. To be precise about attribution: `@Astro-Han` commented on #1196 (which was authored by `@vekovius`), and `@elkaix` authored #1414. The mention was intentionally limited to people with directly related prior reports, following maintainer-radar guidance. + +Note one internal inconsistency in the thread: the #1674 issue body cites the older reports as #1196, #1371, and #1387, while the cc comment (and the framing above) pairs #1196 with #1414. Both reference real prior lag reports; the difference is only which subset each surface lists. + +ClawSweeper kept the issue open. The full current label set (as of this update) is: + +- `P2` +- `clawsweeper:needs-live-repro` +- `clawsweeper:needs-maintainer-review` +- `clawsweeper:needs-product-decision` +- `clawsweeper:no-new-fix-pr` +- `issue-rating: 🐚 platinum hermit` +- `impact:other` + +The `clawsweeper:no-new-fix-pr` label is worth calling out for a reviewer: ClawSweeper does **not** recommend queueing an *automated* fix PR for this issue. The two draft PRs below are human-authored exploratory directions opened deliberately for the maintainer product decision, not automated fixes — so they are consistent with, not contradicted by, that label. + +Its acceptance criteria included: + +```text +swift test --filter StatusMenuOverviewScrollTests +swift test --filter MenuCardViewRecyclingTests +swift test --filter StatusMenuOverviewSubmenuTests +make check +On macOS 27, run a freshly built app with multiple Overview providers and capture before/after scroll profiling or visual proof. +``` + +## Why Two PRs + +There are two plausible fixes, but they make different product and engineering tradeoffs. Mixing them in one patch would make review unclear. + +So we opened two draft PRs as alternatives, not as cumulative patches: + +1. #1675: Lite row +2. #1676: Rich row with AppKit boundary + +Both PRs are draft PRs because maintainers still need to choose the desired UI/performance direction before merge. + +## PR #1675: Lite Row + +PR: https://github.com/steipete/CodexBar/pull/1675 +Branch: `codex/overview-lite-row` +Latest head at the time of this document: `6f680eeb18e37fb329cf7c26b956ded8c967a076` + +### Motivation + +If the root problem is too much hosted SwiftUI content inside the Overview menu, the lowest-risk performance path is to render less content in each Overview row. + +The Lite row direction keeps Overview as a quick provider summary: + +- Provider identity. +- Updated/subtitle state. +- Plan/account/storage text where relevant. +- A compact quota summary. +- Existing click and submenu behavior. + +It intentionally moves rich charts/details out of the Overview row and leaves them in provider detail surfaces. + +### Implementation Summary + +Changed files: + +- `Sources/CodexBar/StatusItemController+MenuTypes.swift` +- `Tests/CodexBarTests/OverviewMenuCardRowViewTests.swift` + +Key implementation points: + +- Replaced the rich `UsageMenuCardHeaderSectionView` + `UsageMenuCardUsageSectionView` Overview composition with a compact summary row. +- Added `LiteSummary`, which derives a bounded summary from precomputed `UsageMenuCardView.Model` fields. +- Explicitly avoids rendering `InlineUsageDashboardContent` in Overview rows. +- Preserves provider click behavior and provider-detail submenus. +- Preserves live subtitle and model refresh semantics through `MenuCardRefreshMonitor`. + +### ClawSweeper Finding And Fix + +ClawSweeper's concrete code finding was: + +> Use the live model for the compact summary. + +The first Lite row patch updated the subtitle through `MenuCardRefreshMonitor`, but built the compact summary and progress tint from `self.model`. That could produce a stale quota/progress summary while the subtitle had already refreshed. + +Follow-up fix: + +- Added `resolvedLiveModel(refreshMonitor:)`. +- Header, compact summary, and progress tint now all derive from the same monitor-resolved model. +- Added focused coverage proving a stale row resolves refreshed progress through the monitor without rebuilding the menu. + +New focused test: + +```text +overview lite summary uses monitor resolved refreshed model +``` + +### Validation + +Local validation run for the Lite row direction: + +```text +swift test --filter OverviewMenuCardRowViewTests +swift test --filter "overview row" +swift build +make check +git diff --check +``` + +The PR body and follow-up comment were updated, and `@clawsweeper re-review` was requested. ClawSweeper acknowledged the re-review command for head `6f680eeb`. + +### Risks + +The Lite row direction changes the product feel of Overview. It likely reduces scroll-time work, but it also removes rich chart/detail content from the Overview list. This needs maintainer/product approval. + +It still lacks an interactive after-fix scroll profile or recording. + +## PR #1676: Rich Row With AppKit Boundary + +PR: https://github.com/steipete/CodexBar/pull/1676 +Branch: `codex/overview-rich-row` +Latest head at the time of this document: `963ed4cf9941cc98300650c5532e7ffcebf1b618` + +### Motivation + +If maintainers want to preserve the current rich Overview UI, we should reduce scroll/hover overhead without removing content. + +The suspected expensive interaction is SwiftUI highlight/hit-test/layout work during `NSMenu` tracking. The rich-row direction therefore keeps the SwiftUI content but moves row-level hover/highlight/hit-test handling to a narrow AppKit wrapper. + +### AppKit Boundary + +This direction uses a small AppKit bridge: + +- SwiftUI still owns the row content and model rendering. +- SwiftUI still receives `menuCardRefreshMonitor`. +- AppKit owns only row-level: + - `hitTest` + - hover background via `CALayer` + - measured/fixed row height behavior + - recycling support + - click glue + +This follows the `build-macos-apps:appkit-interop` guidance: cross only the narrow platform boundary needed for menus instead of rewriting the feature in raw AppKit. + +### Implementation Summary + +Changed files: + +- `Sources/CodexBar/StatusItemController+Menu.swift` +- `Sources/CodexBar/StatusItemController+MenuCardItems.swift` +- `Sources/CodexBar/StatusItemController+MenuPresentation.swift` +- `Sources/CodexBar/StatusItemController+MenuTypes.swift` +- `Tests/CodexBarTests/MenuCardViewRecyclingTests.swift` + +Key implementation points: + +- Added `OverviewMenuRowHostingView`, an AppKit host for Overview rows only. +- Added `makeOverviewMenuRowItem` so normal menu cards keep the existing path. +- Moved row highlight to an AppKit `CALayer`. +- Made `hitTest` stop at the Overview row container boundary. +- Preserved provider click behavior and provider-detail submenus. +- Preserved `MenuCardRefreshMonitor` injection through `OverviewMenuRowContainerView`. +- Guarded repeated intrinsic-size invalidation when row height does not change. + +### Follow-up Test + +ClawSweeper did not report a concrete code defect for #1676. It mainly requested real behavior proof. + +We still added one lifecycle regression test after re-reviewing the AppKit bridge: + +```text +recycled overview row keeps hosting view and clears appkit highlight state +``` + +This verifies that: + +- The hosting view is recycled instead of rebuilt. +- Stale AppKit highlight state is cleared during reuse. + +### Validation + +Local validation run for the Rich row direction: + +```text +swift test --filter "overview row" +swift test --filter "highlight" +swift build +make check +git diff --check +CODEXBAR_SIGNING=adhoc ./Scripts/package_app.sh debug +codesign --verify --deep --strict --verbose=2 CodexBar.app +``` + +The follow-up validation was run on: + +```text +macOS 27.0 (26A5353q) +Apple Swift 6.4 +arm64 +``` + +The PR body and follow-up comment were updated, and `@clawsweeper re-review` was requested. ClawSweeper acknowledged the re-review command for head `963ed4cf`. + +### Risks + +The Rich row direction preserves UI density but adds a new AppKit hosting boundary. The bridge is intentionally narrow, but it touches primary menu rendering and interaction. The main residual risk is runtime behavior under a real `NSMenuTrackingSession`. + +Two specific risks a reviewer should weigh, beyond runtime behavior: + +- **Highlight appearance changes (an undisclosed visual regression).** The new `makeOverviewMenuRowItem` wraps content in `OverviewMenuRowContainerView`, which injects only `menuCardRefreshMonitor` — it does **not** inject `menuItemHighlighted`, does not apply `foregroundStyle(primary(highlighted))`, and does not draw the SwiftUI selection background that `MenuCardSectionContainerView` provides on the existing path. Because the rich header/usage subviews all read `@Environment(\.menuItemHighlighted)` (see `MenuCardView.swift`), that environment now stays `false`, so the row text **no longer inverts on highlight**. Highlight is conveyed only by the `CALayer` background (`selectedContentBackgroundColor` at alpha `0.16`). Net effect: an Overview row highlights as a faint translucent background with dark text, while every other row/submenu in the same menu still uses the standard solid-blue-with-white-text selection — an inconsistent, weaker highlight. The captured `cgColor` is also static and will not follow accent-color or light/dark changes. This is a product-visible change that the PR body currently frames as a neutral "move hover highlight to a cheap CALayer"; it should be disclosed and accepted explicitly. + +- **Duplicated hosting infrastructure.** `OverviewMenuRowHostingView` re-implements much of the existing `MenuCardItemHostingView` (`installClickRecognizer`, `acceptsFirstMouse`, `measuredHeight`, `prepareForReuse`, `allowsVibrancy`), creating two parallel menu-hosting classes to maintain. Reusing or parameterizing the existing host would be a lower-divergence design. + +It still lacks an interactive after-fix scroll profile or recording. + +## Current GitHub State + +As of the latest local `gh` check: + +- Issue #1674 is open. +- PR #1675 is open and draft. +- PR #1676 is open and draft. +- Both PRs are mergeable. +- Both PRs have follow-up commits pushed. +- Both PRs have ClawSweeper re-review queued and acknowledged. +- GitGuardian has passed on both updated heads. +- Other GitHub Actions checks may still be queued or not yet refreshed for the latest heads. + +We intentionally did not wait synchronously for all external checks, because the local proof, PR body updates, and ClawSweeper re-review requests are already done. + +## What Is Proven + +The issue is high-quality enough to keep open: + +- The user supplied a concrete symptom and sample command. +- The sample maps to current source. +- The suspect stack is tied to menu tracking and hosted SwiftUI row layout/render/hit-test. +- The current source has not meaningfully changed in the implicated files after v0.37.0. + +The Lite PR proves: + +- Overview row content can be made lightweight. +- Dashboard-heavy SwiftUI content is avoided in Overview rows. +- The compact summary uses the same monitor-resolved live model as existing usage-row logic. +- Click/submenu behavior remains covered by existing Overview tests. + +The Rich PR proves: + +- Overview can keep the rich row UI while moving highlight/hit-test to AppKit. +- AppKit highlight state is cleared on reuse. +- Overview row hosting views can be recycled. +- Existing overview row action, submenu, rendered-mode, storage text, and scroll targeting tests continue to pass. +- A debug app package can be produced and codesigned locally on macOS 27. + +## What Is Not Proven + +We still do not have the strongest proof ClawSweeper asked for: + +- No controlled before/after interactive scroll recording. +- No after-fix `sample` output captured while scrolling a provider-heavy Overview menu. +- No Instruments trace showing frame-time or main-thread stack reduction. + +This is important because unit tests can prove the row model and bridge lifecycle, but they cannot fully simulate `NSMenuTrackingSession` behavior under real trackpad/mouse-wheel input. + +## Smaller Third Direction (Candidate) + +Both PRs are fairly large for a performance fix. Given the mechanism in "Evidence From The Sample" (each scroll step costs two full rich-row re-renders plus a hit-test descent), there are two smaller, lower-commitment experiments worth trying before settling on either PR: + +1. **Decouple highlight from content re-render, SwiftUI-only.** Make the selection background the *only* thing that depends on the highlight flag, and stop re-coloring the whole subtree via `foregroundStyle(primary(highlighted))` at the container level. This is the SwiftUI-subset of what the Rich PR does in AppKit: it cuts the per-highlight re-render cost without introducing a new `NSView` host class and without changing the highlight's visual style (the background can keep using the standard selection color). + +2. **Drop the redundant synthetic mouse-move.** `postOverviewScrollNavigation` already advances highlight explicitly via `self.menu(menu, willHighlight:)`; the subsequent synthetic `.mouseMoved` then re-drives hit-testing and highlighting (the `NSHostingView.hitTest` / `scrollWheel` work in the sample). The code comment says the mouse-move preserves native highlight/submenu behavior, so this needs a targeted experiment to confirm it is actually redundant — but if it is, removing it is a ~10-line change that hits the hit-test path directly. + +Both still require the same controlled macOS 27 interactive profiling to confirm. The Rich PR is essentially the industrial-strength version of option 1; option 2 is orthogonal and could stack with either PR. + +## Design Tradeoff + +The maintainer decision is likely between these two philosophies: + +### Choose Lite Row If + +- Overview should be a fast high-level summary. +- Full charts/details can live in provider detail surfaces. +- Reducing SwiftUI content is preferred over preserving exact current density. +- The simplest performance path is preferred. + +### Choose Rich Row If + +- Overview should preserve the current information density. +- The performance problem is mostly hover/hit-test/highlight propagation. +- A narrow AppKit menu boundary is acceptable. +- Maintainers want a lower visual-change patch. + +These PRs should not both merge. If maintainers choose one direction, the other should be closed or parked. + +## Recommended Next Steps + +1. Wait for ClawSweeper re-review to finish on both PRs. +2. If ClawSweeper clears the concrete Lite finding, compare remaining comments. +3. Run one controlled interactive proof on macOS 27: + - Build a fresh app from the chosen branch. + - Enable multiple Overview providers. + - Open Overview. + - Scroll while recording a short video or running `sample`. + - Redact local paths/account info before posting summary. +4. Ask maintainers to choose Lite vs Rich based on UI preference and proof. +5. Convert only the selected PR from draft to ready. + +## Questions For Claude Review + +Please review the two PR directions and this reasoning with these questions in mind: + +1. Is the root-cause interpretation coherent from the sample stack and source mapping? +2. Does the Lite row PR fix ClawSweeper's live-model critique completely, or is there another stale-model path? +3. Does the Rich row AppKit bridge cross the smallest reasonable boundary, or does it introduce hidden lifecycle risk? +4. Are there tests missing that would materially increase confidence without requiring a real interactive menu session? +5. Between Lite and Rich, which direction is more likely to be accepted by maintainers, and why? +6. Is there a third direction that is smaller than both PRs and still addresses the scroll hot path? +7. Are the PR bodies honest and precise about what is proven versus what remains unproven? + +## Review Pointers + +The symbols and test files below are introduced by the PR branches and do **not** exist on `main` (where this document lives). Check out the relevant branch or read the PR diff before following these pointers: + +- Lite row: `git checkout codex/overview-lite-row` (PR #1675) — adds `LiteSummary`, `resolvedLiveModel(refreshMonitor:)`, and `OverviewMenuCardRowViewTests`. +- Rich row: `git checkout codex/overview-rich-row` (PR #1676) — adds `OverviewMenuRowHostingView`, `makeOverviewMenuRowItem`, and `OverviewMenuRowContainerView`. + +On `main`, only the unchanged baseline symbols (`addOverviewRows`, `OverviewMenuCardRowView`, `UsageMenuCardUsageSectionView`) are present. + +For Lite row review: + +- Start with `OverviewMenuCardRowView` in `Sources/CodexBar/StatusItemController+MenuTypes.swift`. +- Check `resolvedLiveModel(refreshMonitor:)`. +- Check `LiteSummary.make(for:)`. +- Check `OverviewMenuCardRowViewTests`. + +For Rich row review: + +- Start with `OverviewMenuRowHostingView` in `Sources/CodexBar/StatusItemController+MenuPresentation.swift`. +- Check `makeOverviewMenuRowItem` in `Sources/CodexBar/StatusItemController+MenuCardItems.swift`. +- Check `addOverviewRows` in `Sources/CodexBar/StatusItemController+Menu.swift`. +- Check `MenuCardViewRecyclingTests`. + +For shared behavior: + +- Check `StatusMenuOverviewScrollTests`. +- Check `StatusMenuOverviewSubmenuTests`. +- Check existing menu-card recycling behavior. + +## Bottom Line + +The motivation is solid: a real user-visible scroll stutter maps to a plausible current-source hot path in `NSMenu` plus hosted SwiftUI Overview rows. + +The progress is also concrete: the issue is public, two alternative draft PRs exist, ClawSweeper feedback was addressed where it identified real code problems, and both branches have focused tests plus local validation. + +The main unresolved question is not "can we patch something?" It is which product/performance tradeoff maintainers want, and whether a controlled macOS 27 interactive scroll proof confirms the chosen direction. + +--- + +## Update 2026-06-24: Measured root cause + a third (implemented) direction + +This section adds quantitative evidence collected on macOS 27 (Swift 6.4, debug) and a third +implementation that supersedes the lite/rich split for the render half of the problem. + +### Headless benchmark of one highlight step + +Scrolling the Overview does not scroll pixels — `handleOverviewScrollWheel` converts the wheel into +discrete "move the highlighted row" steps. So the per-scroll cost is the cost of toggling a row's +selection. A headless benchmark hosted a real `OverviewMenuCardRowView` through the production +hosting path and measured `setHighlighted → layout → display → runloop-flush` over 200 toggles: + +```text +A baseline (SwiftUI recolor via menuItemHighlighted) avg ~2.4–10ms max ~7–27ms +B + .drawingGroup() (Metal offscreen rasterization) avg ~2.2–10ms max ~9–32ms (~10–28% only) +C content pinned, highlight fully decoupled avg ~0.01–0.06ms +D container highlight modifiers only, content pinned avg ~3–8ms +E GPU CIColorMatrix tint + AppKit selection layer avg ~0.05ms max ~1–2ms +``` + +Findings: + +- The spikes in `A` (~25ms) line up with the dropped frames in the user's recording (120fps capture, + ~57fps effective, worst frame gaps ~25ms). +- `D` shows the cost is dominated by re-rasterizing the content subtree whenever the container's + highlight-dependent modifiers change — not by leaf body evaluations. +- `B` proves **Metal alone is insufficient**: `.drawingGroup()` speeds rasterization but the SwiftUI + body/transaction pass still runs, so it only buys ~10–28% and still misses the 8.3ms/120Hz budget. +- `E`/`C` show the only way to the 120Hz budget is to take the selection off the SwiftUI graph. + +### Implemented direction: AppKit/GPU selection (`GPUSelectionHostingView`) + +`Sources/CodexBar/MenuCardGPUSelectionView.swift` renders the selected look without any SwiftUI work: + +1. an `NSVisualEffectView(.selection)` background (the existing in-repo pattern from + `PersistentRefreshMenuView`), crossfaded via Core Animation so the highlight glides between rows + instead of teleporting, and +2. a `CIColorMatrix` content filter that maps the row's pixels to the selected text color — which + matches the existing design where every selected element already becomes + `selectedMenuItemTextColor`. Core Image runs on the GPU (Metal), so the toggle is a layer change. + +It is opt-in via `makeMenuCardItem(usesGPUSelection:)` and currently wired only for Overview rows in +`addOverviewRows`. It deliberately does **not** override `hitTest`, avoiding the embedded-control +regression ClawSweeper flagged on the rich-row PR. Measured production path: **2.35ms → 0.044ms** +average per toggle, max well under one 120Hz frame. + +### The second, orthogonal problem: discrete navigation feel + +Even at 0.05ms/step, the motion is not "hand-following" because the wheel is quantized. Driving the +real `handleOverviewScrollWheel` with continuous gestures showed: + +```text +slow swipe: 240px finger travel -> one row jump every 24px (teleport, nothing in between) +fast flick: 200px (intent ~8 rows) -> only 3 rows registered (per-event cap + remainder discarded) +post-flick 20px nudge -> 0 steps (accumulator was zeroed, so the follow-up felt dead) +``` + +Interaction changes in `StatusItemController+OverviewScroll.swift` (merged with a parallel Codex +worktree that independently reached the same GPU-selection design): + +- **Precise trackpad scrolls are passed through to AppKit's native menu scroller** instead of being + converted into thresholded row-highlight jumps (`guard !event.hasPreciseScrollingDeltas { … return + false }`). Trackpads are continuous devices; native menu scrolling follows the finger, which is the + real fix for the "不跟手" feel — making highlight toggles cheap (above) does not by itself remove the + discrete-jump model. Classic notched scroll wheels keep the row-to-row highlight navigation. +- The crossfade in `GPUSelectionHostingView` softens the remaining wheel-driven highlight transitions. + +A deterministic regression test (`gpu selection highlight bypasses swiftui highlight state`) asserts +that highlighting a GPU row marks the AppKit view highlighted while the hosted +`MenuCardHighlightState.isHighlighted` stays `false`, proving selection never re-invalidates the +SwiftUI graph. + +### Test status + +`swift build` clean; the updated `StatusMenuOverviewScrollTests` (precise pass-through cases) and the +menu-card recycling/highlight suites — including the new GPU bypass test — pass. From 1d84a5994bb77a1d0c7827ed24ae672fa0a5f5a6 Mon Sep 17 00:00:00 2001 From: hhh2210 Date: Wed, 24 Jun 2026 22:31:19 +0800 Subject: [PATCH 2/3] refactor: address Copilot review on GPU selection tint and scroll comment - Derive the selection tint bias from NSColor.selectedMenuItemTextColor instead of hard-coding pure white, so graphite/high-contrast/accessibility appearances tint correctly and the code matches its own doc comment. - Clarify the momentum-phase guard comment: precise trackpad/Magic Mouse scrolling already returns earlier, so the guard only covers non-precise devices that still report a momentum phase. Co-Authored-By: Claude Opus 4.8 --- Sources/CodexBar/MenuCardGPUSelectionView.swift | 14 ++++++++++---- .../StatusItemController+OverviewScroll.swift | 5 +++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBar/MenuCardGPUSelectionView.swift b/Sources/CodexBar/MenuCardGPUSelectionView.swift index de5acd2afa..70cf30a4f6 100644 --- a/Sources/CodexBar/MenuCardGPUSelectionView.swift +++ b/Sources/CodexBar/MenuCardGPUSelectionView.swift @@ -108,7 +108,7 @@ final class GPUSelectionHostingView: NSView, MenuCardHighlighting 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 + // 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] : [] @@ -177,15 +177,21 @@ final class GPUSelectionHostingView: NSView, MenuCardHighlighting 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. + /// 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 static func makeSelectedTextTintFilter() -> CIFilter? { guard let filter = CIFilter(name: "CIColorMatrix") else { return nil } + let 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: 1, y: 1, z: 1, w: 0), forKey: "inputBiasVector") + filter.setValue( + CIVector(x: tint.redComponent, y: tint.greenComponent, z: tint.blueComponent, w: 0), + forKey: "inputBiasVector") return filter } } diff --git a/Sources/CodexBar/StatusItemController+OverviewScroll.swift b/Sources/CodexBar/StatusItemController+OverviewScroll.swift index baf8527e5f..5fb980afdd 100644 --- a/Sources/CodexBar/StatusItemController+OverviewScroll.swift +++ b/Sources/CodexBar/StatusItemController+OverviewScroll.swift @@ -30,8 +30,9 @@ 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. + // 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 } From 534a9d0473c5ce201d12764c4cd8d9a4a218663a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 28 Jun 2026 02:49:11 -0700 Subject: [PATCH 3/3] Refresh GPU selection tint for appearance --- .../CodexBar/MenuCardGPUSelectionView.swift | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/MenuCardGPUSelectionView.swift b/Sources/CodexBar/MenuCardGPUSelectionView.swift index 70cf30a4f6..33daa6f4cd 100644 --- a/Sources/CodexBar/MenuCardGPUSelectionView.swift +++ b/Sources/CodexBar/MenuCardGPUSelectionView.swift @@ -21,7 +21,7 @@ import SwiftUI final class GPUSelectionHostingView: NSView, MenuCardHighlighting, MenuCardMeasuring { private let hosting: NSHostingView> private let selectionView = NSVisualEffectView() - private let tintFilter: CIFilter? + private var tintFilter: CIFilter? private var isRowHighlighted = false private var onClick: (() -> Void)? @@ -56,9 +56,10 @@ final class GPUSelectionHostingView: NSView, MenuCardHighlighting self.hosting = NSHostingView(rootView: rootView) self.allowsMenuHighlight = allowsMenuHighlight self.onClick = onClick - self.tintFilter = Self.makeSelectedTextTintFilter() + self.tintFilter = nil super.init(frame: .zero) self.wantsLayer = true + self.refreshTintFilter() self.setupSelectionView() self.setupHosting() if onClick != nil { @@ -83,6 +84,11 @@ final class GPUSelectionHostingView: NSView, MenuCardHighlighting 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 @@ -182,9 +188,19 @@ final class GPUSelectionHostingView: NSView, MenuCardHighlighting /// 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 static func makeSelectedTextTintFilter() -> CIFilter? { + 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 } - let tint = NSColor.selectedMenuItemTextColor.usingColorSpace(.deviceRGB) ?? .white + 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")