686 lines
26 KiB
Swift
686 lines
26 KiB
Swift
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<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()
|
|
}
|
|
|
|
/// 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<Content: View>: NSHostingView<Content>, 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<Content: View>: 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))]
|
|
: [])
|
|
}
|
|
}
|
|
}
|
|
}
|