import AppKit import CodexBarCore import Observation import SwiftUI extension StatusItemController { func switcherWeeklyRemaining(for provider: UsageProvider) -> Double? { let snapshot = self.store.snapshot(for: provider) return Self.switcherWeeklyMetricPercent( for: provider, snapshot: snapshot, showUsed: self.settings.usageBarsShowUsed, preference: self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot)) } func applySubtitle(_ subtitle: String, to item: NSMenuItem, title: String) { if #available(macOS 14.4, *) { // NSMenuItem.subtitle is only available on macOS 14.4+. item.subtitle = subtitle } else { item.view = self.makeMenuSubtitleView(title: title, subtitle: subtitle, isEnabled: item.isEnabled) item.toolTip = "\(title) — \(subtitle)" } } func makeMenuSubtitleView(title: String, subtitle: String, isEnabled: Bool) -> NSView { let container = NSView() container.translatesAutoresizingMaskIntoConstraints = false container.alphaValue = isEnabled ? 1.0 : 0.7 let titleField = NSTextField(labelWithString: title) titleField.font = NSFont.menuFont(ofSize: NSFont.systemFontSize) titleField.textColor = NSColor.labelColor titleField.lineBreakMode = .byTruncatingTail titleField.maximumNumberOfLines = 1 titleField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) let subtitleField = NSTextField(labelWithString: subtitle) subtitleField.font = NSFont.menuFont(ofSize: NSFont.smallSystemFontSize) subtitleField.textColor = NSColor.secondaryLabelColor subtitleField.lineBreakMode = .byTruncatingTail subtitleField.maximumNumberOfLines = 1 subtitleField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) let stack = NSStackView(views: [titleField, subtitleField]) stack.orientation = .vertical stack.alignment = .leading stack.spacing = 1 stack.translatesAutoresizingMaskIntoConstraints = false container.addSubview(stack) NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18), stack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -10), stack.topAnchor.constraint(equalTo: container.topAnchor, constant: 2), stack.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -2), ]) return container } } @MainActor protocol MenuCardHighlighting: AnyObject { var allowsMenuHighlight: Bool { get } func setHighlighted(_ highlighted: Bool) } extension MenuCardHighlighting { var allowsMenuHighlight: Bool { true } } @MainActor protocol MenuCardMeasuring: AnyObject { func measuredHeight(width: CGFloat) -> CGFloat } @MainActor @Observable final class MenuCardHighlightState { var isHighlighted = false } final class MenuHostingView: NSHostingView { /// The height AppKit should give this item's menu row. NSMenu reads `intrinsicContentSize` /// (not the explicit `frame`) when it lays out custom-view rows, so a measured height that /// only lives in `frame` is silently reverted to the open-time row height — leaving the /// SwiftUI content centered in a stale, oversized row. Routing the height through the /// intrinsic size is the channel the menu actually honors. private var measuredHeight: CGFloat? override var allowsVibrancy: Bool { true } override var intrinsicContentSize: NSSize { guard let measuredHeight else { return super.intrinsicContentSize } return NSSize(width: NSView.noIntrinsicMetric, height: measuredHeight) } func applyMeasuredHeight(width: CGFloat, height: CGFloat) { let resolvedHeight = max(1, ceil(height)) guard self.measuredHeight != resolvedHeight || self.frame.height != resolvedHeight else { return } self.measuredHeight = resolvedHeight self.frame = NSRect( origin: self.frame.origin, size: NSSize(width: width, height: resolvedHeight)) self.invalidateIntrinsicContentSize() self.layoutSubtreeIfNeeded() self.superview?.layoutSubtreeIfNeeded() } /// Measures the true SwiftUI content height at `width`. The cached `measuredHeight` is routed /// through `intrinsicContentSize`, so `fittingSize` would otherwise echo the stale cached value; /// clearing it for the measurement lets the live content size drive the result. Used to resize /// the row exactly when expandable content (e.g. status groups) toggles. func measuredFittingHeight(width: CGFloat) -> CGFloat { let saved = self.measuredHeight self.measuredHeight = nil self.frame = NSRect(origin: self.frame.origin, size: NSSize(width: width, height: 1)) self.invalidateIntrinsicContentSize() self.layoutSubtreeIfNeeded() let height = self.fittingSize.height self.measuredHeight = saved return height } } @MainActor final class MenuCardItemHostingView: NSHostingView, MenuCardHighlighting, MenuCardMeasuring { let highlightState: MenuCardHighlightState private(set) var allowsMenuHighlight: Bool private var onClick: (() -> Void)? private var containsInteractiveControls: Bool let interactiveRegionStore: MenuCardInteractiveRegionStore? private var isPressed = false private var isForwardingHostedControlPress = false #if DEBUG private var testForwardedHostedControlMouseDown = false private var testForwardedHostedControlMouseUp = false #endif override var allowsVibrancy: Bool { true } override var intrinsicContentSize: NSSize { let size = super.intrinsicContentSize guard self.frame.width > 0 else { return size } return NSSize(width: self.frame.width, height: size.height) } init( rootView: Content, highlightState: MenuCardHighlightState, allowsMenuHighlight: Bool, containsInteractiveControls: Bool = false, interactiveRegionStore: MenuCardInteractiveRegionStore? = nil, onClick: (() -> Void)? = nil) { self.highlightState = highlightState self.allowsMenuHighlight = allowsMenuHighlight self.containsInteractiveControls = containsInteractiveControls self.interactiveRegionStore = interactiveRegionStore self.onClick = onClick super.init(rootView: rootView) } /// Reuses this hosting view for a rebuilt card with the same identity: the replaced /// `rootView` is diffed in place by SwiftUI instead of tearing down and recreating the /// hosting view and its graph. Callers must construct `rootView` around this view's own /// `highlightState` so menu hover highlighting keeps driving the rendered content. func prepareForReuse( rootView: Content, allowsMenuHighlight: Bool, containsInteractiveControls: Bool = false, onClick: (() -> Void)?) { self.rootView = rootView self.allowsMenuHighlight = allowsMenuHighlight self.containsInteractiveControls = containsInteractiveControls self.onClick = onClick self.isPressed = false self.isForwardingHostedControlPress = false } /// `NSMenu` tracking consumes keyboard events before they reach a menu item's custom view, so /// the pointer `onClick` path has no native counterpart for assistive tech. Expose the row as an /// accessibility button whose press mirrors a click, giving VoiceOver an activation path that runs /// `onClick` (and therefore keeps the menu open) instead of regressing to mouse-only. 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 } required init(rootView: Content) { self.highlightState = MenuCardHighlightState() self.allowsMenuHighlight = false self.containsInteractiveControls = false self.interactiveRegionStore = nil self.onClick = nil super.init(rootView: rootView) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } override func hitTest(_ point: NSPoint) -> NSView? { let descendant = super.hitTest(point) if let descendant { var current: NSView? = descendant while let view = current, view !== self { if view is NSButton || view is NSControl { return descendant } current = view.superview } if self.hitsHostedInteractiveControl(at: point) { return descendant } if descendant !== self, self.onClick != nil { return self } } return descendant } private func locationInView(for event: NSEvent) -> NSPoint { guard self.window != nil else { return event.locationInWindow } return self.convert(event.locationInWindow, from: nil) } override func mouseDown(with event: NSEvent) { guard event.type == .leftMouseDown, self.onClick != nil else { super.mouseDown(with: event) return } let localPoint = self.locationInView(for: event) if self.beginPrimaryPress(at: localPoint) { #if DEBUG self.testForwardedHostedControlMouseDown = true #endif super.mouseDown(with: event) } } override func mouseUp(with event: NSEvent) { guard event.type == .leftMouseUp, self.onClick != nil else { super.mouseUp(with: event) return } let result = self.endPrimaryPress(at: self.locationInView(for: event)) if result.forwardToHostedControl { #if DEBUG self.testForwardedHostedControlMouseUp = true #endif super.mouseUp(with: event) return } if result.invokeRowAction { self.onClick?() } } /// Returns whether AppKit should forward the press into a nested SwiftUI control. private func beginPrimaryPress(at point: NSPoint) -> Bool { if self.hitsHostedInteractiveControl(at: point) { self.isForwardingHostedControlPress = true return true } self.isPressed = self.bounds.contains(point) return false } private func endPrimaryPress(at point: NSPoint) -> (forwardToHostedControl: Bool, invokeRowAction: Bool) { if self.isForwardingHostedControlPress { self.isForwardingHostedControlPress = false return (true, false) } defer { self.isPressed = false } return (false, self.isPressed && self.bounds.contains(point)) } private func hitsHostedInteractiveControl(at point: NSPoint) -> Bool { self.containsInteractiveControls && (self.interactiveRegionStore?.contains( point, hostingBounds: self.bounds, fittedSize: self.fittingSize) == true) } func measuredHeight(width: CGFloat) -> CGFloat { self.frame = NSRect(origin: self.frame.origin, size: NSSize(width: width, height: 1)) self.layoutSubtreeIfNeeded() return self.fittingSize.height } func setHighlighted(_ highlighted: Bool) { guard self.highlightState.isHighlighted != highlighted else { return } self.highlightState.isHighlighted = highlighted } } @MainActor final class PersistentRefreshMenuView: NSView, MenuCardHighlighting { private static let minimumShortcutColumnWidth: CGFloat = 44 private static let titleShortcutGap: CGFloat = 8 private static let shortcutReferenceText = "⌘ R" private let selectionView = NSVisualEffectView() private let iconView = NSImageView() private let titleField: NSTextField private let shortcutField: NSTextField? private var isRowHighlighted = false private var isRowEnabled = true private var rowHeight = PersistentRefreshRowMetrics.defaults.rowHeight private var onClick: (() -> Void)? override var allowsVibrancy: Bool { true } override var intrinsicContentSize: NSSize { NSSize(width: self.frame.width, height: self.rowHeight) } init( title: String, systemImageName: String?, shortcutText: String?, onClick: (() -> Void)? = nil) { self.titleField = NSTextField(labelWithString: title) self.shortcutField = shortcutText.map(NSTextField.init(labelWithString:)) self.onClick = onClick super.init(frame: .zero) self.setupSelectionView() self.setupIconView(systemImageName: systemImageName) self.setupTextFields() if onClick != nil { self.installClickRecognizer() } self.updateColors() } @available(*, unavailable) required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } override func accessibilityRole() -> NSAccessibility.Role? { self.onClick == nil ? super.accessibilityRole() : .button } override func accessibilityLabel() -> String? { self.titleField.stringValue } override func isAccessibilityEnabled() -> Bool { self.isRowEnabled } override func accessibilityPerformPress() -> Bool { guard self.isRowEnabled else { return false } guard let onClick = self.onClick else { return super.accessibilityPerformPress() } onClick() return true } func applySize(width: CGFloat, height: CGFloat) { self.rowHeight = max(1, ceil(height)) self.frame = NSRect(origin: .zero, size: NSSize(width: width, height: self.rowHeight)) self.invalidateIntrinsicContentSize() self.needsLayout = true } func setHighlighted(_ highlighted: Bool) { guard self.isRowHighlighted != highlighted else { return } self.isRowHighlighted = highlighted self.selectionView.isHidden = !highlighted self.updateColors() } func setEnabled(_ enabled: Bool) { guard self.isRowEnabled != enabled else { return } self.isRowEnabled = enabled if !enabled { self.isRowHighlighted = false self.selectionView.isHidden = true } self.updateColors() } override func layout() { super.layout() let metrics = PersistentRefreshRowMetrics.defaults self.selectionView.frame = self.bounds.insetBy( dx: metrics.selectionHorizontalInset, dy: metrics.selectionVerticalInset) self.selectionView.layer?.cornerRadius = metrics.selectionCornerRadius var leadingX = metrics.leadingPadding if self.iconView.image != nil { let iconSide = metrics.iconWidth self.iconView.symbolConfiguration = Self.iconConfiguration(for: metrics) self.iconView.frame = NSRect( x: leadingX, y: floor((self.bounds.height - iconSide) / 2), width: iconSide, height: iconSide) leadingX += metrics.iconWidth + metrics.iconTitleSpacing } var titleMaxX = self.bounds.maxX - metrics.trailingPadding if let shortcutField { shortcutField.font = Self.shortcutFont(for: metrics) let shortcutSize = shortcutField.intrinsicContentSize let referenceWidth = Self.shortcutReferenceWidth(for: metrics) let shortcutColumnWidth = max(Self.minimumShortcutColumnWidth, referenceWidth, shortcutSize.width) let shortcutX = self.bounds.maxX - metrics.trailingPadding + metrics.shortcutXOffset - referenceWidth shortcutField.frame = NSRect( x: shortcutX, y: floor((self.bounds.height - shortcutSize.height) / 2) + metrics.shortcutYOffset, width: shortcutColumnWidth, height: shortcutSize.height) titleMaxX = shortcutX - Self.titleShortcutGap } let titleSize = self.titleField.intrinsicContentSize self.titleField.frame = NSRect( x: leadingX, y: floor((self.bounds.height - titleSize.height) / 2), width: max(0, titleMaxX - leadingX), height: titleSize.height) } private func setupSelectionView() { self.selectionView.material = .selection self.selectionView.blendingMode = .withinWindow self.selectionView.state = .active self.selectionView.isEmphasized = true self.selectionView.isHidden = true self.selectionView.wantsLayer = true self.selectionView.layer?.masksToBounds = true self.addSubview(self.selectionView) } private func setupIconView(systemImageName: String?) { guard let systemImageName, let baseImage = NSImage(systemSymbolName: systemImageName, accessibilityDescription: nil) else { self.iconView.isHidden = true return } baseImage.isTemplate = true self.iconView.image = baseImage self.iconView.symbolConfiguration = Self.iconConfiguration(for: PersistentRefreshRowMetrics.defaults) self.iconView.imageScaling = .scaleProportionallyDown self.iconView.contentTintColor = .labelColor self.addSubview(self.iconView) } private func setupTextFields() { // Title truncates, shortcut clips; configuring them separately keeps the shortcut column stable. self.titleField.font = NSFont.menuFont(ofSize: 0) self.configureTitleField(self.titleField) if let shortcutField { shortcutField.font = Self.shortcutFont(for: PersistentRefreshRowMetrics.defaults) self.configureShortcutField(shortcutField) } } private func configureTitleField(_ field: NSTextField) { field.lineBreakMode = .byTruncatingTail field.maximumNumberOfLines = 1 field.allowsDefaultTighteningForTruncation = true field.backgroundColor = .clear self.addSubview(field) } private func configureShortcutField(_ field: NSTextField) { field.alignment = .left field.lineBreakMode = .byClipping field.maximumNumberOfLines = 1 field.allowsDefaultTighteningForTruncation = false field.backgroundColor = .clear self.addSubview(field) } private func installClickRecognizer() { let recognizer = NSClickGestureRecognizer(target: self, action: #selector(self.handlePrimaryClick(_:))) recognizer.buttonMask = 0x1 self.addGestureRecognizer(recognizer) } private func updateColors() { guard self.isRowEnabled else { self.titleField.textColor = .disabledControlTextColor self.shortcutField?.textColor = .disabledControlTextColor self.iconView.contentTintColor = .disabledControlTextColor return } if self.isRowHighlighted { self.titleField.textColor = .selectedMenuItemTextColor self.shortcutField?.textColor = .selectedMenuItemTextColor self.iconView.contentTintColor = .selectedMenuItemTextColor return } self.titleField.textColor = .labelColor self.shortcutField?.textColor = .tertiaryLabelColor self.iconView.contentTintColor = .labelColor } private static func iconConfiguration(for metrics: PersistentRefreshRowMetrics) -> NSImage.SymbolConfiguration { NSImage.SymbolConfiguration(pointSize: metrics.iconSymbolPointSize, weight: metrics.iconSymbolWeight) } private static func shortcutFont(for metrics: PersistentRefreshRowMetrics) -> NSFont { NSFont.menuFont(ofSize: metrics.shortcutFontSize) } private static func shortcutReferenceWidth(for metrics: PersistentRefreshRowMetrics) -> CGFloat { (self.shortcutReferenceText as NSString).size(withAttributes: [ .font: self.shortcutFont(for: metrics), ]).width } @objc private func handlePrimaryClick(_ recognizer: NSClickGestureRecognizer) { guard recognizer.state == .ended else { return } guard self.isRowEnabled else { return } self.onClick?() } } #if DEBUG extension MenuCardItemHostingView { var _test_forwardedHostedControlEvents: (mouseDown: Bool, mouseUp: Bool) { (self.testForwardedHostedControlMouseDown, self.testForwardedHostedControlMouseUp) } func _test_hitsHostedInteractiveControl(at point: NSPoint) -> Bool { self.hitsHostedInteractiveControl(at: point) } func _test_simulateRuntimeClick(at point: NSPoint? = nil) -> Bool { let clickPoint = point ?? NSPoint(x: self.bounds.midX, y: self.bounds.midY) guard let onClick = self.onClick else { return false } guard !self.beginPrimaryPress(at: clickPoint) else { _ = self.endPrimaryPress(at: clickPoint) return false } let result = self.endPrimaryPress(at: clickPoint) guard result.invokeRowAction else { return false } onClick() return true } } #endif struct MenuCardSectionContainerView: View { @Bindable var highlightState: MenuCardHighlightState let showsSubmenuIndicator: Bool let submenuIndicatorAlignment: Alignment let submenuIndicatorTopPadding: CGFloat var refreshMonitor: MenuCardRefreshMonitor? var interactiveRegionStore: MenuCardInteractiveRegionStore? @ViewBuilder let content: () -> Content init( highlightState: MenuCardHighlightState, showsSubmenuIndicator: Bool, submenuIndicatorAlignment: Alignment, submenuIndicatorTopPadding: CGFloat, refreshMonitor: MenuCardRefreshMonitor?, interactiveRegionStore: MenuCardInteractiveRegionStore? = nil, @ViewBuilder content: @escaping () -> Content) { self.highlightState = highlightState self.showsSubmenuIndicator = showsSubmenuIndicator self.submenuIndicatorAlignment = submenuIndicatorAlignment self.submenuIndicatorTopPadding = submenuIndicatorTopPadding self.refreshMonitor = refreshMonitor self.interactiveRegionStore = interactiveRegionStore self.content = content } var body: some View { self.content() .environment(\.menuItemHighlighted, self.highlightState.isHighlighted) .environment(\.menuCardRefreshMonitor, self.refreshMonitor) .coordinateSpace(name: MenuCardInteractiveRegionPreferenceKey.coordinateSpaceName) .onPreferenceChange(MenuCardInteractiveRegionPreferenceKey.self) { regions in self.interactiveRegionStore?.regions = regions } .foregroundStyle(MenuHighlightStyle.primary(self.highlightState.isHighlighted)) .background(alignment: .topLeading) { if self.highlightState.isHighlighted { RoundedRectangle(cornerRadius: 6, style: .continuous) .fill(MenuHighlightStyle.selectionBackground(true)) .padding(.horizontal, 6) .padding(.vertical, 2) } } .overlay(alignment: self.submenuIndicatorAlignment) { if self.showsSubmenuIndicator { Image(systemName: "chevron.right") .font(.caption2.weight(.semibold)) .foregroundStyle(MenuHighlightStyle.secondary(self.highlightState.isHighlighted)) .padding(.top, self.submenuIndicatorTopPadding) .padding(.trailing, 10) } } } } @MainActor @Observable final class MenuCardInteractiveRegionStore { var regions: [CGRect] = [] func contains(_ point: CGPoint, hostingBounds: CGRect, fittedSize: CGSize) -> Bool { // NSHostingView centers intrinsic-height SwiftUI content in the menu item's padded frame. // Preference rectangles use the SwiftUI root's coordinates, so remove that AppKit offset. let contentOrigin = CGPoint( x: max(0, (hostingBounds.width - fittedSize.width) / 2), y: max(0, (hostingBounds.height - fittedSize.height) / 2)) let contentPoint = CGPoint(x: point.x - contentOrigin.x, y: point.y - contentOrigin.y) return self.regions.contains { $0.contains(contentPoint) } } } struct MenuCardInteractiveRegionPreferenceKey: PreferenceKey { static let coordinateSpaceName = "MenuCardInteractiveRegion" static let defaultValue: [CGRect] = [] static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) { value.append(contentsOf: nextValue()) } } extension View { func menuCardInteractiveControl(isEnabled: Bool = true) -> some View { self.background { GeometryReader { proxy in Color.clear.preference( key: MenuCardInteractiveRegionPreferenceKey.self, value: isEnabled ? [proxy.frame(in: .named(MenuCardInteractiveRegionPreferenceKey.coordinateSpaceName))] : []) } } } }