391 lines · 15.6 KB · Swift
RawDownload
import AppKitimport CodexBarCoreimport Observationimport SwiftUI extension StatusItemController {    func switcherWeeklyRemaining(for provider: UsageProvider) -> Double? {        Self.switcherWeeklyMetricPercent(            for: provider,            snapshot: self.store.snapshot(for: provider),            showUsed: self.settings.usageBarsShowUsed)    }     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    }} @MainActorprotocol MenuCardHighlighting: AnyObject {    var allowsMenuHighlight: Bool { get }    func setHighlighted(_ highlighted: Bool)} extension MenuCardHighlighting {    var allowsMenuHighlight: Bool {        true    }} @MainActorprotocol MenuCardMeasuring: AnyObject {    func measuredHeight(width: CGFloat) -> CGFloat} @MainActor@Observablefinal class MenuCardHighlightState {    var isHighlighted = false} final class MenuHostingView<Content: View>: NSHostingView<Content> {    /// 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()    }} @MainActorfinal class MenuCardItemHostingView<Content: View>: NSHostingView<Content>, MenuCardHighlighting, MenuCardMeasuring {    let highlightState: MenuCardHighlightState    private(set) var allowsMenuHighlight: Bool    private var onClick: (() -> Void)?    private var hasClickRecognizer = false     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,        onClick: (() -> Void)? = nil)    {        self.highlightState = highlightState        self.allowsMenuHighlight = allowsMenuHighlight        self.onClick = onClick        super.init(rootView: rootView)        if onClick != nil {            self.installClickRecognizer()        }    }     /// 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, onClick: (() -> Void)?) {        self.rootView = rootView        self.allowsMenuHighlight = allowsMenuHighlight        self.onClick = onClick        if onClick != nil, !self.hasClickRecognizer {            self.installClickRecognizer()        }    }     private func installClickRecognizer() {        let recognizer = NSClickGestureRecognizer(target: self, action: #selector(self.handlePrimaryClick(_:)))        recognizer.buttonMask = 0x1        self.addGestureRecognizer(recognizer)        self.hasClickRecognizer = true    }     required init(rootView: Content) {        self.highlightState = MenuCardHighlightState()        self.allowsMenuHighlight = false        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    }     @objc private func handlePrimaryClick(_ recognizer: NSClickGestureRecognizer) {        guard recognizer.state == .ended else { return }        self.onClick?()    }     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    }} struct MenuCardSectionContainerView<Content: View>: View {    @Bindable var highlightState: MenuCardHighlightState    let showsSubmenuIndicator: Bool    let submenuIndicatorAlignment: Alignment    let submenuIndicatorTopPadding: CGFloat    var refreshMonitor: MenuCardRefreshMonitor?    @ViewBuilder let content: () -> Content     var body: some View {        self.content()            .environment(\.menuItemHighlighted, self.highlightState.isHighlighted)            .environment(\.menuCardRefreshMonitor, self.refreshMonitor)            .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)                }            }    }} @MainActorfinal class PersistentMenuActionItemView: NSView, MenuCardHighlighting {    static let rowHeight: CGFloat = 28     private let backgroundView = NSView()    private let imageView = NSImageView()    private let progressIndicator = NSProgressIndicator()    private let titleField: NSTextField    private let shortcutField: NSTextField    private let onClick: () -> Void    private var isInProgress = false     override var intrinsicContentSize: NSSize {        NSSize(width: self.frame.width > 0 ? self.frame.width : NSView.noIntrinsicMetric, height: Self.rowHeight)    }     override var fittingSize: NSSize {        NSSize(width: self.frame.width, height: Self.rowHeight)    }     override func setFrameSize(_ newSize: NSSize) {        super.setFrameSize(NSSize(width: newSize.width, height: Self.rowHeight))    }     init(        title: String,        systemImageName: String?,        shortcutText: String?,        width: CGFloat,        onClick: @escaping () -> Void)    {        self.titleField = NSTextField(labelWithString: title)        self.shortcutField = NSTextField(labelWithString: shortcutText ?? "")        self.onClick = onClick        super.init(frame: NSRect(origin: .zero, size: NSSize(width: width, height: Self.rowHeight)))        self.setupView(systemImageName: systemImageName)        self.setHighlighted(false)    }     @available(*, unavailable)    required init?(coder: NSCoder) {        fatalError("init(coder:) has not been implemented")    }     override func acceptsFirstMouse(for event: NSEvent?) -> Bool {        true    }     override func mouseUp(with event: NSEvent) {        guard event.type == .leftMouseUp else { return }        self.onClick()    }     func setHighlighted(_ highlighted: Bool) {        let primaryColor = highlighted ? NSColor.selectedMenuItemTextColor : NSColor.controlTextColor        let secondaryColor = highlighted ? NSColor.selectedMenuItemTextColor : NSColor.secondaryLabelColor        self.backgroundView.isHidden = !highlighted        self.titleField.textColor = primaryColor        self.shortcutField.textColor = secondaryColor        self.imageView.contentTintColor = primaryColor        self.progressIndicator.appearance = highlighted ? NSAppearance(named: .darkAqua) : nil    }     /// Gives the persistent Refresh row immediate, in-place feedback while a manual refresh    /// is in flight: the leading `arrow.clockwise` icon is swapped for a spinner occupying the    /// same fixed 18×18 slot, so the row height and layout never change (no menu rebuild).    func setInProgress(_ inProgress: Bool) {        guard self.isInProgress != inProgress else { return }        self.isInProgress = inProgress        // Fade the icon rather than `isHidden`: a hidden NSStackView arranged subview collapses        // its slot and shifts the title, whereas `alphaValue` keeps the fixed 18pt slot in place.        self.imageView.alphaValue = inProgress ? 0 : 1        self.progressIndicator.isHidden = !inProgress        if inProgress {            self.progressIndicator.startAnimation(nil)        } else {            self.progressIndicator.stopAnimation(nil)        }    }     #if DEBUG    var isInProgressForTesting: Bool {        self.isInProgress    }    #endif     private func setupView(systemImageName: String?) {        self.backgroundView.wantsLayer = true        self.backgroundView.layer?.cornerRadius = 6        self.backgroundView.layer?.backgroundColor = NSColor.selectedContentBackgroundColor.cgColor        self.backgroundView.translatesAutoresizingMaskIntoConstraints = false        self.addSubview(self.backgroundView)         if let systemImageName,           let image = NSImage(systemSymbolName: systemImageName, accessibilityDescription: nil)        {            image.isTemplate = true            image.size = NSSize(width: 16, height: 16)            self.imageView.image = image        }        self.imageView.translatesAutoresizingMaskIntoConstraints = false         self.progressIndicator.style = .spinning        self.progressIndicator.controlSize = .small        self.progressIndicator.isIndeterminate = true        self.progressIndicator.isDisplayedWhenStopped = false        self.progressIndicator.isHidden = true        self.progressIndicator.translatesAutoresizingMaskIntoConstraints = false         self.titleField.font = NSFont.menuFont(ofSize: NSFont.systemFontSize)        self.titleField.lineBreakMode = .byTruncatingTail        self.titleField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)        self.titleField.translatesAutoresizingMaskIntoConstraints = false         self.shortcutField.font = NSFont.menuFont(ofSize: NSFont.smallSystemFontSize)        self.shortcutField.alignment = .right        self.shortcutField.lineBreakMode = .byTruncatingTail        self.shortcutField.setContentHuggingPriority(.required, for: .horizontal)        self.shortcutField.setContentCompressionResistancePriority(.required, for: .horizontal)        self.shortcutField.translatesAutoresizingMaskIntoConstraints = false         let spacer = NSView()        spacer.translatesAutoresizingMaskIntoConstraints = false        spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)         let stack = NSStackView()        stack.orientation = .horizontal        stack.alignment = .centerY        stack.spacing = 8        stack.translatesAutoresizingMaskIntoConstraints = false        stack.addArrangedSubview(self.imageView)        stack.addArrangedSubview(self.titleField)        stack.addArrangedSubview(spacer)        stack.addArrangedSubview(self.shortcutField)        self.addSubview(stack)        // The spinner overlaps the icon's fixed slot so toggling it never changes row metrics.        self.addSubview(self.progressIndicator)         NSLayoutConstraint.activate([            self.backgroundView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 6),            self.backgroundView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -6),            self.backgroundView.topAnchor.constraint(equalTo: self.topAnchor, constant: 2),            self.backgroundView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -2),             self.imageView.widthAnchor.constraint(equalToConstant: 18),            self.imageView.heightAnchor.constraint(equalToConstant: 18),            self.shortcutField.widthAnchor.constraint(equalToConstant: 38),             self.progressIndicator.centerXAnchor.constraint(equalTo: self.imageView.centerXAnchor),            self.progressIndicator.centerYAnchor.constraint(equalTo: self.imageView.centerYAnchor),            self.progressIndicator.widthAnchor.constraint(equalToConstant: 16),            self.progressIndicator.heightAnchor.constraint(equalToConstant: 16),             stack.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 12),            stack.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -12),            stack.centerYAnchor.constraint(equalTo: self.centerYAnchor),        ])    }}