mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
feat(bar): swipeable profile carousel and condensed alerts (#1603)
Closes #1602
This commit is contained in:
1 parent
0d20a102bd
commit
d7933c6243
2 files changed
+284
-59
No files matched your search
@@ -43,6 +43,14 @@ struct BarMenuView: View {
|
|||||||
// Per-provider carousel position: provider -> selected profile row id. Each
|
// Per-provider carousel position: provider -> selected profile row id. Each
|
||||||
// provider has its own profile carousel, so selection is tracked per provider.
|
// provider has its own profile carousel, so selection is tracked per provider.
|
||||||
@State private var selectedProfileByProvider: [String: String] = [:]
|
@State private var selectedProfileByProvider: [String: String] = [:]
|
||||||
|
/// Live horizontal drag translation per provider while a swipe is in progress.
|
||||||
|
/// Reset to 0 (and committed to a page change) on drag release. Keyed by
|
||||||
|
/// provider so each carousel tracks its own in-flight swipe independently.
|
||||||
|
@State private var dragByProvider: [String: CGFloat] = [:]
|
||||||
|
/// Whether the Alerts section is expanded to show every alert. Collapsed by
|
||||||
|
/// default: only the most-severe few render, with a "+N more" toggle, so a
|
||||||
|
/// burst of conditions never buries the cockpit under a wall of rows.
|
||||||
|
@State private var alertsExpanded = false
|
||||||
|
|
||||||
// MARK: - Screen cap
|
// MARK: - Screen cap
|
||||||
|
|
||||||
@@ -91,14 +99,10 @@ struct BarMenuView: View {
|
|||||||
|
|
||||||
// (2) ALERTS — urgent quota crossings surface above accounts.
|
// (2) ALERTS — urgent quota crossings surface above accounts.
|
||||||
// Spend-cap alerts are opt-in OFF by default, so by default only
|
// Spend-cap alerts are opt-in OFF by default, so by default only
|
||||||
// quota/reauth/cooldown conditions appear here.
|
// quota/reauth/cooldown conditions appear here. Deduped, severity-
|
||||||
|
// ranked, compact, and collapsed past a few — see alertsSection.
|
||||||
if !viewModel.activeAlerts.isEmpty {
|
if !viewModel.activeAlerts.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
alertsSection
|
||||||
SectionLabel("Alerts")
|
|
||||||
ForEach(viewModel.activeAlerts) { alert in
|
|
||||||
AlertRow(alert: alert)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// (3) SUBSCRIPTIONS — the dominant section, opens here.
|
// (3) SUBSCRIPTIONS — the dominant section, opens here.
|
||||||
@@ -177,13 +181,15 @@ struct BarMenuView: View {
|
|||||||
// Disarm quit on every popover open so a stale armed state never persists.
|
// Disarm quit on every popover open so a stale armed state never persists.
|
||||||
quitArmed = false
|
quitArmed = false
|
||||||
// Reset each provider's carousel to its first profile on every open — KISS,
|
// Reset each provider's carousel to its first profile on every open — KISS,
|
||||||
// no persistence needed.
|
// no persistence needed. Clear any in-flight drag and re-collapse alerts.
|
||||||
selectedProfileByProvider = [:]
|
selectedProfileByProvider = [:]
|
||||||
|
dragByProvider = [:]
|
||||||
|
alertsExpanded = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "Update available" banner. Shown when `viewModel.updateAvailable` is true.
|
/// "Update available" banner. Shown when `viewModel.updateAvailable` is true.
|
||||||
/// Styled to match the existing AlertRow / ErrorBanner patterns (tinted
|
/// Styled to match the existing CompactAlertRow / ErrorBanner patterns (tinted
|
||||||
/// background card, section label, borderless button).
|
/// background card, section label, borderless button).
|
||||||
@ViewBuilder private var updateBanner: some View {
|
@ViewBuilder private var updateBanner: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
@@ -277,37 +283,9 @@ struct BarMenuView: View {
|
|||||||
onRefresh: { viewModel.forceRefresh() })
|
onRefresh: { viewModel.forceRefresh() })
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
profileCarousel(prov: prov, rows: rows)
|
||||||
HStack(spacing: 0) {
|
|
||||||
ForEach(rows) { row in
|
|
||||||
BarSubscriptionCard(
|
|
||||||
row: row, isParked: row.paused,
|
|
||||||
onRefresh: { viewModel.forceRefresh() })
|
|
||||||
// Each card spans exactly the horizontal scroll viewport so
|
|
||||||
// .paging snaps to one whole card per page. A fixed width
|
|
||||||
// wider than the viewport (the 360 popover minus the content
|
|
||||||
// VStack's 14pt padding leaves ~332pt) drifts a few points on
|
|
||||||
// every page and leaves the card stuck off-center after
|
|
||||||
// paging forward and back. containerRelativeFrame keeps the
|
|
||||||
// card width == viewport width, so paging stays aligned and
|
|
||||||
// the card is centered like the single-profile card.
|
|
||||||
.containerRelativeFrame(.horizontal)
|
|
||||||
.id(row.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// scrollTargetLayout on the HStack (the paging container) snaps to
|
|
||||||
// each profile card on .paging behavior.
|
|
||||||
.scrollTargetLayout()
|
|
||||||
}
|
|
||||||
.scrollTargetBehavior(.paging)
|
|
||||||
.scrollPosition(id: Binding(
|
|
||||||
get: { selectedProfileByProvider[prov] ?? rows.first?.id },
|
|
||||||
set: { selectedProfileByProvider[prov] = $0 ?? rows.first?.id }))
|
|
||||||
.frame(height: carouselHeight(rows))
|
|
||||||
// Page controls — clickable prev/next arrows + dots so the carousel
|
// Page controls — clickable prev/next arrows + dots so the carousel
|
||||||
// is navigable by MOUSE (click), not just by trackpad swipe.
|
// is also navigable by MOUSE click, not only by drag/swipe.
|
||||||
// Programmatically setting the scrollPosition binding pages the
|
|
||||||
// ScrollView; withAnimation gives the same feel as a swipe.
|
|
||||||
carouselControls(prov: prov, rows: rows)
|
carouselControls(prov: prov, rows: rows)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -372,9 +350,77 @@ struct BarMenuView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Swipeable profile pager: one full-width card visible at a time, an HStack of
|
||||||
|
/// the provider's cards offset to the current page. Two complementary inputs
|
||||||
|
/// move between pages so no device is left out:
|
||||||
|
/// - a `DragGesture` for a mouse/trackpad press-drag (the previous ScrollView
|
||||||
|
/// never paged on a plain mouse click-drag), committing on release past a
|
||||||
|
/// 20% threshold, with the ends rubber-banding so an over-drag resists;
|
||||||
|
/// - a `CarouselScrollPager` overlay that turns a non-clicking horizontal
|
||||||
|
/// trackpad / Magic Mouse swipe (delivered as scroll-wheel events, which a
|
||||||
|
/// DragGesture does not see) into a page step.
|
||||||
|
/// No ScrollView, so there is no paging drift and the card is always centered.
|
||||||
|
@ViewBuilder private func profileCarousel(prov: String, rows: [BarSummaryRow]) -> some View {
|
||||||
|
let currentId = selectedProfileByProvider[prov] ?? rows.first?.id
|
||||||
|
let curIdx = rows.firstIndex(where: { $0.id == currentId }) ?? 0
|
||||||
|
GeometryReader { geo in
|
||||||
|
let pageWidth = geo.size.width
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(rows) { row in
|
||||||
|
BarSubscriptionCard(
|
||||||
|
row: row, isParked: row.paused,
|
||||||
|
onRefresh: { viewModel.forceRefresh() })
|
||||||
|
.frame(width: pageWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.offset(x: -CGFloat(curIdx) * pageWidth + (dragByProvider[prov] ?? 0))
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.gesture(
|
||||||
|
DragGesture(minimumDistance: 8)
|
||||||
|
.onChanged { value in
|
||||||
|
// Rubber-band at the ends: an over-drag past the first/last card moves
|
||||||
|
// at a third the rate so it springs back instead of revealing a gap.
|
||||||
|
let raw = value.translation.width
|
||||||
|
let atStart = curIdx == 0 && raw > 0
|
||||||
|
let atEnd = curIdx == rows.count - 1 && raw < 0
|
||||||
|
dragByProvider[prov] = (atStart || atEnd) ? raw / 3 : raw
|
||||||
|
}
|
||||||
|
.onEnded { value in
|
||||||
|
// Commit a page change when the swipe passes 20% of the page width;
|
||||||
|
// otherwise snap back to the current card.
|
||||||
|
let threshold = pageWidth * 0.2
|
||||||
|
var newIdx = curIdx
|
||||||
|
if value.translation.width <= -threshold { newIdx = min(curIdx + 1, rows.count - 1) }
|
||||||
|
else if value.translation.width >= threshold { newIdx = max(curIdx - 1, 0) }
|
||||||
|
withAnimation(.easeOut(duration: 0.2)) {
|
||||||
|
dragByProvider[prov] = 0
|
||||||
|
selectedProfileByProvider[prov] = rows[newIdx].id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
// Trackpad / Magic Mouse horizontal swipe (scroll-wheel events) → page step.
|
||||||
|
// Transparent to clicks and to the DragGesture; only observes scroll.
|
||||||
|
.overlay(
|
||||||
|
CarouselScrollPager { step in page(prov: prov, by: step, rows: rows) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.frame(height: carouselHeight(rows))
|
||||||
|
.clipped() // hide the neighbouring cards that sit outside the page viewport
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Step the given provider's carousel by ±1 page, clamped to the ends. Used by
|
||||||
|
/// the horizontal scroll-swipe overlay; the arrows/dots call `selectPage`
|
||||||
|
/// directly.
|
||||||
|
private func page(prov: String, by step: Int, rows: [BarSummaryRow]) {
|
||||||
|
let currentId = selectedProfileByProvider[prov] ?? rows.first?.id
|
||||||
|
let curIdx = rows.firstIndex(where: { $0.id == currentId }) ?? 0
|
||||||
|
let newIdx = min(max(curIdx + step, 0), rows.count - 1)
|
||||||
|
if newIdx != curIdx { selectPage(prov, rows[newIdx].id) }
|
||||||
|
}
|
||||||
|
|
||||||
/// Prev/next arrows + clickable dots for a provider's profile carousel, so it
|
/// Prev/next arrows + clickable dots for a provider's profile carousel, so it
|
||||||
/// is navigable by MOUSE (click), not only by trackpad swipe. Selecting a page
|
/// is navigable by MOUSE click, not only by drag/swipe. Selecting a page sets
|
||||||
/// sets the scrollPosition binding, which scrolls the ScrollView to that card.
|
/// `selectedProfileByProvider`, which animates the pager offset to that card.
|
||||||
@ViewBuilder private func carouselControls(prov: String, rows: [BarSummaryRow]) -> some View {
|
@ViewBuilder private func carouselControls(prov: String, rows: [BarSummaryRow]) -> some View {
|
||||||
let currentId = selectedProfileByProvider[prov] ?? rows.first?.id
|
let currentId = selectedProfileByProvider[prov] ?? rows.first?.id
|
||||||
let curIdx = rows.firstIndex(where: { $0.id == currentId }) ?? 0
|
let curIdx = rows.firstIndex(where: { $0.id == currentId }) ?? 0
|
||||||
@@ -430,6 +476,92 @@ struct BarMenuView: View {
|
|||||||
return 40 + CGFloat(maxWindows) * 20 + (hasFootnote ? 16 : 0)
|
return 40 + CGFloat(maxWindows) * 20 + (hasFootnote ? 16 : 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Alerts
|
||||||
|
|
||||||
|
/// One displayed alert after de-duplication: the representative notification
|
||||||
|
/// plus how many identical conditions it stands for (e.g. the same "ck needs
|
||||||
|
/// re-authentication" firing on two surfaces collapses to one row with ×2).
|
||||||
|
private struct GroupedAlert: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let alert: BarNotification
|
||||||
|
let count: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/// De-duplicate alerts by their visible text and rank by severity so the most
|
||||||
|
/// actionable condition leads. Two alerts that render identically (same title +
|
||||||
|
/// body) collapse into one group with a count, killing the "ck reauth" /
|
||||||
|
/// "ck reauth" / "ck paused" / "ck paused" repetition seen with multi-surface
|
||||||
|
/// profiles.
|
||||||
|
private func groupedAlerts(_ alerts: [BarNotification]) -> [GroupedAlert] {
|
||||||
|
var order: [String] = []
|
||||||
|
var byKey: [String: (alert: BarNotification, count: Int)] = [:]
|
||||||
|
for a in alerts {
|
||||||
|
let key = a.title + "\u{1F}" + a.body
|
||||||
|
if let hit = byKey[key] {
|
||||||
|
byKey[key] = (hit.alert, hit.count + 1)
|
||||||
|
} else {
|
||||||
|
byKey[key] = (a, 1)
|
||||||
|
order.append(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return order
|
||||||
|
.map { GroupedAlert(id: $0, alert: byKey[$0]!.alert, count: byKey[$0]!.count) }
|
||||||
|
.sorted { alertSeverityRank($0.alert.kind) < alertSeverityRank($1.alert.kind) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Severity order for alert ranking: reauth (account unusable) first, spend
|
||||||
|
/// caps next, then quota, then the soft paused/cooldown note.
|
||||||
|
private func alertSeverityRank(_ kind: BarAlertKind) -> Int {
|
||||||
|
switch kind {
|
||||||
|
case .reauthNeeded: return 0
|
||||||
|
case .dailySpendAbove, .monthSpendAbove: return 1
|
||||||
|
case .quotaRemainingBelow: return 2
|
||||||
|
case .accountCooldownOrPaused: return 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calm, compact alerts: a labelled header with a total count, then a few
|
||||||
|
/// single-line rows (most-severe first). Collapsed past `collapsedCap` behind a
|
||||||
|
/// "+N more" toggle so a burst of conditions never floods the popover. Replaces
|
||||||
|
/// the previous stack of tall two-line cards.
|
||||||
|
@ViewBuilder private var alertsSection: some View {
|
||||||
|
let groups = groupedAlerts(viewModel.activeAlerts)
|
||||||
|
let collapsedCap = 3
|
||||||
|
let overflow = groups.count - collapsedCap
|
||||||
|
let visible = alertsExpanded ? groups : Array(groups.prefix(collapsedCap))
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
SectionLabel("Alerts")
|
||||||
|
Text("\(groups.count)")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.padding(.horizontal, 5)
|
||||||
|
.padding(.vertical, 1)
|
||||||
|
.background(Color.secondary.opacity(0.18), in: Capsule())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
ForEach(visible) { g in
|
||||||
|
CompactAlertRow(alert: g.alert, count: g.count)
|
||||||
|
}
|
||||||
|
if overflow > 0 {
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.15)) { alertsExpanded.toggle() }
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: alertsExpanded ? "chevron.up" : "chevron.down")
|
||||||
|
.font(.system(size: 9, weight: .bold))
|
||||||
|
Text(alertsExpanded ? "Show less" : "\(overflow) more")
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(nsImage: MenuBarIcon.headerImage())
|
Image(nsImage: MenuBarIcon.headerImage())
|
||||||
@@ -789,33 +921,38 @@ struct ErrorBanner: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One in-dropdown alert row. Mirrors a delivered notification so the conditions
|
/// Compact, single-line alert row used by the condensed Alerts section. One
|
||||||
/// are visible even when system notifications are denied. The icon is keyed off
|
/// glanceable line — kind icon + the self-describing body (the title is dropped
|
||||||
/// the alert kind so each rule reads at a glance.
|
/// as redundant with the icon) + an optional ×N when several identical
|
||||||
struct AlertRow: View {
|
/// conditions were merged. The tint is softer than the old `AlertRow` card so a
|
||||||
|
/// list of them reads as informative, not alarming.
|
||||||
|
struct CompactAlertRow: View {
|
||||||
@Environment(\.barTheme) private var theme
|
@Environment(\.barTheme) private var theme
|
||||||
let alert: BarNotification
|
let alert: BarNotification
|
||||||
|
let count: Int
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top, spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.foregroundStyle(tint)
|
.foregroundStyle(tint)
|
||||||
.font(.caption)
|
.font(.caption2)
|
||||||
.padding(.top, 1)
|
.frame(width: 12)
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
Text(alert.body)
|
||||||
Text(alert.title)
|
.font(.caption2)
|
||||||
.font(.caption.weight(.medium))
|
.foregroundStyle(.secondary)
|
||||||
Text(alert.body)
|
.lineLimit(1)
|
||||||
.font(.caption2)
|
.truncationMode(.tail)
|
||||||
|
if count > 1 {
|
||||||
|
Text("×\(count)")
|
||||||
|
.font(.system(size: 9, weight: .semibold))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(2)
|
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 5)
|
.padding(.vertical, 4)
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(tint.opacity(0.10), in: RoundedRectangle(cornerRadius: 7))
|
.background(tint.opacity(0.08), in: RoundedRectangle(cornerRadius: 6))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var icon: String {
|
private var icon: String {
|
||||||
@@ -828,8 +965,6 @@ struct AlertRow: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var tint: Color {
|
private var tint: Color {
|
||||||
// Themed: quota warnings take the brand accent, reauth the critical band,
|
|
||||||
// so alert chips match the rest of the dropdown on both plates.
|
|
||||||
switch alert.kind {
|
switch alert.kind {
|
||||||
case .quotaRemainingBelow: return theme.accent
|
case .quotaRemainingBelow: return theme.accent
|
||||||
case .dailySpendAbove, .monthSpendAbove: return theme.accent
|
case .dailySpendAbove, .monthSpendAbove: return theme.accent
|
||||||
@@ -868,3 +1003,93 @@ struct Chip: View {
|
|||||||
.foregroundStyle(textColor)
|
.foregroundStyle(textColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adds horizontal trackpad / Magic Mouse swipe paging to the profile carousel.
|
||||||
|
/// SwiftUI's `DragGesture` handles a mouse or trackpad press-drag, but a
|
||||||
|
/// non-clicking two-finger swipe arrives as scroll-wheel events it never sees.
|
||||||
|
///
|
||||||
|
/// This hosts a transparent AppKit anchor view (click- and drag-transparent via
|
||||||
|
/// a nil `hitTest`, so it never blocks the cards' buttons or the DragGesture) and
|
||||||
|
/// a local scroll-wheel monitor scoped to that view's on-screen frame. A
|
||||||
|
/// predominantly horizontal scroll pages once per gesture; a vertical scroll is
|
||||||
|
/// passed straight through so the popover still scrolls. If the frame math ever
|
||||||
|
/// fails to match, the worst case is that scroll-swipe simply does nothing —
|
||||||
|
/// drag and the arrows/dots still work — so the failure mode is benign.
|
||||||
|
struct CarouselScrollPager: NSViewRepresentable {
|
||||||
|
/// Called with +1 (next) or -1 (previous) when a horizontal swipe commits.
|
||||||
|
let onPage: (Int) -> Void
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator { Coordinator(onPage: onPage) }
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> NSView {
|
||||||
|
let view = PassthroughView()
|
||||||
|
context.coordinator.attach(to: view)
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ nsView: NSView, context: Context) {
|
||||||
|
context.coordinator.onPage = onPage
|
||||||
|
}
|
||||||
|
|
||||||
|
static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) {
|
||||||
|
coordinator.detach()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anchor view that is transparent to all mouse hit-testing, so clicks and the
|
||||||
|
/// SwiftUI DragGesture pass through to the cards beneath it.
|
||||||
|
final class PassthroughView: NSView {
|
||||||
|
override func hitTest(_ point: NSPoint) -> NSView? { nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owns the local scroll-wheel monitor and the per-gesture accumulator.
|
||||||
|
final class Coordinator {
|
||||||
|
var onPage: (Int) -> Void
|
||||||
|
private weak var view: NSView?
|
||||||
|
private var monitor: Any?
|
||||||
|
private var accumulated: CGFloat = 0
|
||||||
|
private var firedThisGesture = false
|
||||||
|
private let threshold: CGFloat = 40
|
||||||
|
|
||||||
|
init(onPage: @escaping (Int) -> Void) { self.onPage = onPage }
|
||||||
|
|
||||||
|
func attach(to view: NSView) {
|
||||||
|
self.view = view
|
||||||
|
monitor = NSEvent.addLocalMonitorForEvents(matching: [.scrollWheel]) { [weak self] event in
|
||||||
|
self?.handle(event) ?? event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func detach() {
|
||||||
|
if let monitor { NSEvent.removeMonitor(monitor) }
|
||||||
|
monitor = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return nil to consume a horizontal swipe inside the carousel; return the
|
||||||
|
/// event unchanged otherwise so vertical popover scroll is never swallowed.
|
||||||
|
private func handle(_ event: NSEvent) -> NSEvent? {
|
||||||
|
guard let view, let window = view.window, event.window === window else { return event }
|
||||||
|
let frameInWindow = view.convert(view.bounds, to: nil)
|
||||||
|
guard frameInWindow.contains(event.locationInWindow) else { return event }
|
||||||
|
|
||||||
|
let dx = event.scrollingDeltaX
|
||||||
|
let dy = event.scrollingDeltaY
|
||||||
|
guard abs(dx) > abs(dy) else { return event } // vertical → let the popover scroll
|
||||||
|
|
||||||
|
if event.phase.contains(.began) || event.momentumPhase.contains(.began) {
|
||||||
|
accumulated = 0
|
||||||
|
firedThisGesture = false
|
||||||
|
}
|
||||||
|
accumulated += dx
|
||||||
|
if !firedThisGesture && abs(accumulated) >= threshold {
|
||||||
|
firedThisGesture = true
|
||||||
|
// Natural scrolling: content moving left (negative dx) advances to next.
|
||||||
|
onPage(accumulated < 0 ? 1 : -1)
|
||||||
|
}
|
||||||
|
if event.phase.contains(.ended) || event.momentumPhase.contains(.ended) {
|
||||||
|
accumulated = 0
|
||||||
|
firedThisGesture = false
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.9.1
|
1.9.2
|
||||||
Reference in new issue
Block a user