mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-16 19:44:14 +00:00
fix(menubar): make provider strip reachable and mouse-wheel scrollable
Two related bugs in the macOS menubar `AgentTabStrip`: 1. With more detected providers than fit at the default 360pt popover width (~7+), the off-screen provider chips were unreachable. SwiftUI's horizontal `ScrollView` does not scroll from click-drag, and there was no other affordance to reveal the hidden tabs. 2. Independent mouse wheels could not scroll the horizontal strip. Standard wheels emit only vertical `deltaY` with `hasPreciseScrollingDeltas == false`, and a horizontal SwiftUI `ScrollView` ignores vertical-only deltas. Trackpads (which emit horizontal deltas natively) already worked. Fix: - Wrap the strip in `ScrollViewReader` and add overflow-aware left/right chevron buttons that programmatically scroll to the next/previous visible provider via `proxy.scrollTo(_, anchor: .center)`. Buttons only appear when `stripContentWidth > stripViewportWidth - 30` and disable at the ends. - Install an `NSEvent.addLocalMonitorForEvents(matching: .scrollWheel)` in `.onAppear` (removed in `.onDisappear`). When the cursor is over the strip and the event is non-precise (`!hasPreciseScrollingDeltas`) with `deltaX≈0` and `deltaY≠0`, copy the `CGEvent` and transpose `scrollWheelEventDeltaAxis1` / `PointDeltaAxis1` / `FixedPtDeltaAxis1` onto axis 2 so the underlying NSScrollView receives a real horizontal delta. - Track strip hover via a `@MainActor` singleton `AgentTabStripScrollState` so the local-monitor closure can read the latest hover status without capturing stale SwiftUI state. Trackpad events are passed through untouched, so vertical scrolling elsewhere in the popover is unaffected. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2ca92a97cf
commit
8f35dcd128
1 changed files with 152 additions and 13 deletions
|
|
@ -1,26 +1,111 @@
|
|||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Shared state read by the NSEvent local monitor closure. The closure
|
||||
/// snapshots its captured environment at install time, so SwiftUI @State
|
||||
/// can't be used directly — a reference-type holder keeps the latest hover
|
||||
/// status visible to the monitor across SwiftUI updates.
|
||||
@MainActor
|
||||
final class AgentTabStripScrollState {
|
||||
static let shared = AgentTabStripScrollState()
|
||||
var isStripHovered: Bool = false
|
||||
}
|
||||
|
||||
struct AgentTabStrip: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
@State private var stripViewportWidth: CGFloat = 0
|
||||
@State private var stripContentWidth: CGFloat = 0
|
||||
@State private var scrollWheelMonitor: Any?
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 5) {
|
||||
ForEach(visibleFilters) { filter in
|
||||
AgentTab(
|
||||
filter: filter,
|
||||
cost: cost(for: filter),
|
||||
isActive: store.selectedProvider == filter,
|
||||
quota: store.quotaSummary(for: filter)
|
||||
) {
|
||||
store.switchTo(provider: filter)
|
||||
GeometryReader { viewportGeo in
|
||||
ScrollViewReader { proxy in
|
||||
HStack(spacing: 4) {
|
||||
if isOverflowing {
|
||||
Button {
|
||||
selectAdjacentProvider(direction: -1, proxy: proxy)
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.frame(width: 18, height: 18)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(canMoveBackward ? Color.primary : Color.secondary.opacity(0.35))
|
||||
.disabled(!canMoveBackward)
|
||||
.help("Show previous providers")
|
||||
}
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 5) {
|
||||
ForEach(visibleFilters) { filter in
|
||||
AgentTab(
|
||||
filter: filter,
|
||||
cost: cost(for: filter),
|
||||
isActive: store.selectedProvider == filter,
|
||||
quota: store.quotaSummary(for: filter)
|
||||
) {
|
||||
store.switchTo(provider: filter)
|
||||
withAnimation(.easeInOut(duration: 0.18)) {
|
||||
proxy.scrollTo(filter.id, anchor: .center)
|
||||
}
|
||||
}
|
||||
.id(filter.id)
|
||||
}
|
||||
}
|
||||
.background(
|
||||
GeometryReader { contentGeo in
|
||||
Color.clear
|
||||
.onAppear {
|
||||
stripContentWidth = contentGeo.size.width
|
||||
}
|
||||
.onChange(of: contentGeo.size.width) { _, newWidth in
|
||||
stripContentWidth = newWidth
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 4)
|
||||
.onHover { hovering in
|
||||
AgentTabStripScrollState.shared.isStripHovered = hovering
|
||||
}
|
||||
|
||||
if isOverflowing {
|
||||
Button {
|
||||
selectAdjacentProvider(direction: 1, proxy: proxy)
|
||||
} label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.frame(width: 18, height: 18)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(canMoveForward ? Color.primary : Color.secondary.opacity(0.35))
|
||||
.disabled(!canMoveForward)
|
||||
.help("Show next providers")
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
stripViewportWidth = viewportGeo.size.width
|
||||
installScrollWheelMonitorIfNeeded()
|
||||
withAnimation(.easeInOut(duration: 0.18)) {
|
||||
proxy.scrollTo(store.selectedProvider.id, anchor: .center)
|
||||
}
|
||||
}
|
||||
.onChange(of: viewportGeo.size.width) { _, newWidth in
|
||||
stripViewportWidth = newWidth
|
||||
}
|
||||
.onChange(of: store.selectedProvider) { _, newProvider in
|
||||
withAnimation(.easeInOut(duration: 0.18)) {
|
||||
proxy.scrollTo(newProvider.id, anchor: .center)
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
removeScrollWheelMonitorIfNeeded()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.frame(height: 38)
|
||||
}
|
||||
|
||||
private var todayAll: MenubarPayload {
|
||||
|
|
@ -55,6 +140,60 @@ struct AgentTabStrip: View {
|
|||
sum + (providers[key] ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
private var currentFilterIndex: Int {
|
||||
visibleFilters.firstIndex(of: store.selectedProvider) ?? 0
|
||||
}
|
||||
|
||||
private var canMoveBackward: Bool { currentFilterIndex > 0 }
|
||||
private var canMoveForward: Bool { currentFilterIndex < visibleFilters.count - 1 }
|
||||
private var isOverflowing: Bool { stripContentWidth > (stripViewportWidth - 30) }
|
||||
|
||||
private func selectAdjacentProvider(direction: Int, proxy: ScrollViewProxy) {
|
||||
guard !visibleFilters.isEmpty else { return }
|
||||
let targetIndex = min(max(currentFilterIndex + direction, 0), visibleFilters.count - 1)
|
||||
let target = visibleFilters[targetIndex]
|
||||
store.switchTo(provider: target)
|
||||
withAnimation(.easeInOut(duration: 0.18)) {
|
||||
proxy.scrollTo(target.id, anchor: .center)
|
||||
}
|
||||
}
|
||||
|
||||
/// Standard mouse wheels emit vertical-only scroll deltas, which a horizontal
|
||||
/// `ScrollView` ignores. While the cursor is over the strip we transpose
|
||||
/// vertical-axis scroll fields onto the horizontal axis so the underlying
|
||||
/// NSScrollView receives a real horizontal delta. Trackpad events (precise
|
||||
/// deltas, with native horizontal component) are passed through untouched
|
||||
/// so vertical scrolling elsewhere in the popover is unaffected.
|
||||
private func installScrollWheelMonitorIfNeeded() {
|
||||
guard scrollWheelMonitor == nil else { return }
|
||||
scrollWheelMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in
|
||||
guard AgentTabStripScrollState.shared.isStripHovered,
|
||||
!event.hasPreciseScrollingDeltas,
|
||||
abs(event.scrollingDeltaX) < 0.001,
|
||||
abs(event.scrollingDeltaY) > 0,
|
||||
let cg = event.cgEvent?.copy() else {
|
||||
return event
|
||||
}
|
||||
let lineDeltaY = cg.getIntegerValueField(.scrollWheelEventDeltaAxis1)
|
||||
let pointDeltaY = cg.getDoubleValueField(.scrollWheelEventPointDeltaAxis1)
|
||||
let fixedDeltaY = cg.getDoubleValueField(.scrollWheelEventFixedPtDeltaAxis1)
|
||||
cg.setIntegerValueField(.scrollWheelEventDeltaAxis1, value: 0)
|
||||
cg.setDoubleValueField(.scrollWheelEventPointDeltaAxis1, value: 0)
|
||||
cg.setDoubleValueField(.scrollWheelEventFixedPtDeltaAxis1, value: 0)
|
||||
cg.setIntegerValueField(.scrollWheelEventDeltaAxis2, value: lineDeltaY)
|
||||
cg.setDoubleValueField(.scrollWheelEventPointDeltaAxis2, value: pointDeltaY)
|
||||
cg.setDoubleValueField(.scrollWheelEventFixedPtDeltaAxis2, value: fixedDeltaY)
|
||||
return NSEvent(cgEvent: cg) ?? event
|
||||
}
|
||||
}
|
||||
|
||||
private func removeScrollWheelMonitorIfNeeded() {
|
||||
if let monitor = scrollWheelMonitor {
|
||||
NSEvent.removeMonitor(monitor)
|
||||
scrollWheelMonitor = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AgentTab: View {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue