Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

2062 lines
72 KiB
Swift

import AppKit
import SwiftUI
import MacToolsPluginKit
enum MenuBarPanelLayout {
static let baseWidth: CGFloat = 316
static let secondaryPanelWidth: CGFloat = 216
static let maximumPanelHeight: CGFloat = 720
static let minimumPanelHeight: CGFloat = 220
static let featureListMaximumHeight: CGFloat = 860
static let featurePanelScreenHeightRatio: CGFloat = 0.75
static let screenVerticalMargin: CGFloat = 48
static let cornerRadius: CGFloat = 12
static let panelSpacing: CGFloat = 10
static let outerPadding: CGFloat = 6
static let contentTopPadding: CGFloat = 4
static let rootSpacing: CGFloat = 0
static let toolbarHeight: CGFloat = 30
static let featureRowSpacing: CGFloat = 5
static let rowHeaderHeight: CGFloat = 31
static let rowVerticalPadding: CGFloat = 16
static let detailSpacing: CGFloat = 8
static let detailControlSpacing: CGFloat = 8
static let emptyContentHeight: CGFloat = 150
static let actionRowVerticalPadding: CGFloat = 8
static let selectRowVerticalPadding: CGFloat = 5
static let sliderVerticalPadding: CGFloat = 9
static let navigationRowHeight: CGFloat = 52
static let secondaryPanelMinimumHeight: CGFloat = 148
static var surfaceWidth: CGFloat {
baseWidth - (outerPadding * 2)
}
static var topChromeHeight: CGFloat {
outerPadding + toolbarHeight + rootSpacing
}
static var contentBottomPadding: CGFloat {
outerPadding
}
static var contentVerticalPadding: CGFloat {
contentTopPadding + contentBottomPadding
}
static func contentBodyHeight(forContentHeight contentHeight: CGFloat) -> CGFloat {
max(0, contentHeight - contentVerticalPadding)
}
static var minimumContentHeight: CGFloat {
max(0, minimumPanelHeight - topChromeHeight)
}
static func maximumContentHeight(for screen: NSScreen?) -> CGFloat {
max(
minimumContentHeight,
maximumPanelHeight(for: screen) - topChromeHeight
)
}
static func panelHeight(forContentHeight contentHeight: CGFloat) -> CGFloat {
topChromeHeight + contentHeight
}
static func width(for panelItems: [PluginPanelItem]) -> CGFloat {
baseWidth
}
static func contentSize(for panelItems: [PluginPanelItem]) -> NSSize {
NSSize(
width: width(for: panelItems),
height: preferredPanelHeight(for: panelItems, screen: nil)
)
}
static func height(for panelItems: [PluginPanelItem]) -> CGFloat {
preferredPanelHeight(for: panelItems, screen: nil)
}
static func featureContentHeight(for panelItems: [PluginPanelItem]) -> CGFloat {
let rowContentHeight = panelItems.reduce(CGFloat(0)) { partialResult, item in
partialResult + rowHeight(for: item)
}
let featureSpacing = CGFloat(max(panelItems.count - 1, 0)) * featureRowSpacing
return panelItems.isEmpty
? emptyContentHeight
: rowContentHeight + featureSpacing
}
static func availableFeatureHeight(forPanelHeight panelHeight: CGFloat) -> CGFloat {
max(0, panelHeight - topChromeHeight - contentVerticalPadding)
}
static func preferredPanelHeight(for panelItems: [PluginPanelItem], screen: NSScreen?) -> CGFloat {
panelHeight(
forContentHeight: preferredFeatureContentHeight(for: panelItems, screen: screen)
)
}
static func preferredFeatureContentHeight(for panelItems: [PluginPanelItem], screen: NSScreen?) -> CGFloat {
max(
featureListHeight(for: panelItems, screen: screen) + contentVerticalPadding,
minimumContentHeight
)
}
static func featureListHeight(for panelItems: [PluginPanelItem], screen: NSScreen?) -> CGFloat {
min(featureContentHeight(for: panelItems), maximumFeatureListHeight(for: screen))
}
static func featureListHeight(featureContentHeight: CGFloat, maximumFeatureListHeight: CGFloat) -> CGFloat {
min(featureContentHeight, maximumFeatureListHeight)
}
static func preferredPanelHeight(
featureContentHeight: CGFloat,
maximumFeatureListHeight: CGFloat
) -> CGFloat {
panelHeight(
forContentHeight: preferredFeatureContentHeight(
featureContentHeight: featureContentHeight,
maximumFeatureListHeight: maximumFeatureListHeight
)
)
}
static func preferredFeatureContentHeight(
featureContentHeight: CGFloat,
maximumFeatureListHeight: CGFloat
) -> CGFloat {
max(
featureListHeight(
featureContentHeight: featureContentHeight,
maximumFeatureListHeight: maximumFeatureListHeight
) + contentVerticalPadding,
minimumContentHeight
)
}
static func maximumFeatureListHeight(for screen: NSScreen?) -> CGFloat {
maximumFeatureListHeight(visibleFrameHeight: screen?.visibleFrame.height)
}
static func maximumFeatureListHeight(visibleFrameHeight: CGFloat?) -> CGFloat {
guard let visibleFrameHeight else {
return featureListMaximumHeight
}
let screenMaximum = (visibleFrameHeight * featurePanelScreenHeightRatio)
- topChromeHeight
- contentVerticalPadding
return max(0, min(featureListMaximumHeight, screenMaximum))
}
static func maximumPanelHeight(for screen: NSScreen?) -> CGFloat {
maximumPanelHeight(visibleFrameHeight: screen?.visibleFrame.height)
}
static func maximumPanelHeight(visibleFrameHeight: CGFloat?) -> CGFloat {
guard let visibleFrameHeight else {
return maximumPanelHeight
}
return max(minimumPanelHeight, visibleFrameHeight * featurePanelScreenHeightRatio)
}
private static func rowHeight(for item: PluginPanelItem) -> CGFloat {
guard let detail = displayedDetail(for: item) else {
return rowHeaderHeight + rowVerticalPadding
}
return rowHeaderHeight
+ detailSpacing
+ detailHeight(for: detail.primaryControls)
+ rowVerticalPadding
}
private static func displayedDetail(for item: PluginPanelItem) -> PluginPanelDetail? {
guard let detail = item.detail else {
return nil
}
if item.controlStyle == .disclosure && !item.isExpanded {
return nil
}
return detail
}
private static func detailHeight(for controls: [PluginPanelControl]) -> CGFloat {
controls.enumerated().reduce(CGFloat(0)) { partialResult, element in
let (index, control) = element
let controlSpacing = index == 0 ? CGFloat(0) : detailControlSpacing
let dividerHeight = control.showsLeadingDivider ? CGFloat(8) : CGFloat(0)
return partialResult + controlSpacing + dividerHeight + controlHeight(for: control)
}
}
private static func controlHeight(for control: PluginPanelControl) -> CGFloat {
switch control.kind {
case .segmented:
return 24
case .datePicker:
switch control.datePickerStyle ?? .compact {
case .compact:
return 26
case .dateTimeCard:
return 64
}
case .selectList:
let titleHeight = control.sectionTitle == nil ? CGFloat(0) : CGFloat(15)
return titleHeight + CGFloat(control.options.count) * 26
case .navigationList:
return CGFloat(control.options.count) * navigationRowHeight
case .slider:
let titleHeight = control.sectionTitle == nil && control.valueLabel == nil ? CGFloat(0) : CGFloat(15)
let titleSpacing = titleHeight > 0 ? CGFloat(6) : CGFloat(0)
return titleHeight + titleSpacing + 18 + sliderVerticalPadding * 2
case .switchRow:
return 20 + actionRowVerticalPadding * 2
case .actionRow:
return 16 + actionRowVerticalPadding * 2
}
}
}
private enum FeatureRowLayout {
static let iconSize: CGFloat = 26
static let iconCornerRadius: CGFloat = 10
static let rowSpacing: CGFloat = 10
static let detailControlHorizontalPadding: CGFloat = 10
static let detailLeadingInset: CGFloat = iconSize + rowSpacing - detailControlHorizontalPadding
static let rowHorizontalPadding: CGFloat = 10
static let rowVerticalPadding: CGFloat = MenuBarPanelLayout.rowVerticalPadding / 2
static let chevronSize: CGFloat = 14
}
private enum MenuBarHoverStyle {
static let cornerRadius: CGFloat = MenuBarPanelLayout.cornerRadius
static let fill = Color.primary.opacity(0.06)
static let inset: CGFloat = 1
static let navigationCornerRadius: CGFloat = 8
static let navigationFill = Color.primary.opacity(0.10)
static let navigationSelectedFill = Color.primary.opacity(0.13)
}
@MainActor
final class HoverSecondaryPanelCoordinator: ObservableObject {
struct Activation: Equatable, Hashable {
let pluginID: String
let controlID: String
let optionID: String
}
@Published private(set) var activeActivation: Activation?
@Published private(set) var selectedRowFrame: CGRect?
var onDismissRequest: ((Activation) -> Void)?
private let dismissDelay: Duration
private let activationDelay: Duration?
private var activationTask: Task<Void, Never>?
private var pendingActivation: Activation?
private var dismissTask: Task<Void, Never>?
private var isPanelHovered = false
private var rowFrames: [Activation: CGRect] = [:]
init(
dismissDelay: Duration = .milliseconds(160),
activationDelay: Duration? = .milliseconds(60)
) {
self.dismissDelay = dismissDelay
self.activationDelay = activationDelay
}
func hoverBegan(
pluginID: String,
controlID: String,
optionID: String
) {
let activation = Activation(
pluginID: pluginID,
controlID: controlID,
optionID: optionID
)
cancelDismissal()
isPanelHovered = false
guard activeActivation != activation else {
selectedRowFrame = rowFrames[activation]
return
}
cancelPendingActivation()
guard let activationDelay else {
activate(activation)
return
}
pendingActivation = activation
activationTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: activationDelay)
guard !Task.isCancelled else {
return
}
self?.activate(activation)
}
}
private func activate(_ activation: Activation) {
cancelPendingActivation()
activeActivation = activation
selectedRowFrame = rowFrames[activation]
}
func hoverEnded(
pluginID: String,
controlID: String,
optionID: String
) {
let activation = Activation(
pluginID: pluginID,
controlID: controlID,
optionID: optionID
)
if pendingActivation == activation {
cancelPendingActivation()
scheduleDismissIfNeeded(expectedActivation: activeActivation)
return
}
scheduleDismissIfNeeded(expectedActivation: activation)
}
func setPanelHovered(_ isHovered: Bool) {
isPanelHovered = isHovered
if isHovered {
cancelDismissal()
} else {
scheduleDismissIfNeeded(expectedActivation: activeActivation)
}
}
func updateRowFrame(_ frame: CGRect?, for activation: Activation) {
if let frame {
rowFrames[activation] = frame
} else {
rowFrames.removeValue(forKey: activation)
}
guard activeActivation == activation else {
return
}
selectedRowFrame = frame
}
func dismissImmediately() {
cancelPendingActivation()
dismissInternal(notify: true)
}
private func scheduleDismissIfNeeded(expectedActivation: Activation?) {
cancelDismissal()
guard
let expectedActivation,
activeActivation == expectedActivation
else {
return
}
dismissTask = Task { [dismissDelay] in
try? await Task.sleep(for: dismissDelay)
guard !Task.isCancelled else {
return
}
dismissIfNeeded(expectedActivation)
}
}
private func dismissIfNeeded(_ expectedActivation: Activation) {
guard
activeActivation == expectedActivation,
!isPanelHovered
else {
return
}
dismissInternal(notify: true)
}
private func dismissInternal(notify: Bool) {
cancelDismissal()
guard let activation = activeActivation else {
selectedRowFrame = nil
isPanelHovered = false
return
}
activeActivation = nil
selectedRowFrame = nil
isPanelHovered = false
if notify {
onDismissRequest?(activation)
}
}
private func cancelDismissal() {
dismissTask?.cancel()
dismissTask = nil
}
private func cancelPendingActivation() {
activationTask?.cancel()
activationTask = nil
pendingActivation = nil
}
}
struct MenuBarContent: View {
static let diskCleanWindowID = "disk-clean"
static let diskCleanOpenDetailsActionID = "disk-clean-open-details"
static let launchControlWindowID = "launch-control"
static let launchControlOpenManagerActionID = "launch-control-open-manager"
static let fanControlPluginID = "fan-control"
static let fanControlManagePresetsActionID = "fan-add-preset"
static let zshConfigPluginID = "zsh-config"
static let zshConfigOpenSettingsActionID = "execute"
static let batteryChargeLimitPluginID = "battery-charge-limit"
static let batteryChargeLimitManageSettingsActionID = "battery-manage-settings"
static let ipOverviewPluginID = "ip-overview"
static let ipOverviewCopyIPActionID = "ip-overview-copy-ip"
@StateObject private var secondaryPanelController = SecondaryPanelController()
@StateObject private var hoverCoordinator = HoverSecondaryPanelCoordinator()
@StateObject private var deferredActionDispatcher = DeferredPanelActionDispatcher()
@ObservedObject var pluginHost: PluginHost
let contentBodyHeight: CGFloat
let maximumFeatureListHeight: CGFloat
let isPanelVisible: Bool
let onDismiss: () -> Void
let onOpenSettings: () -> Void
let onPresentDiskCleanConfiguration: () -> Void
let onPresentLaunchControlConfiguration: () -> Void
var body: some View {
content
.background(
MenuWindowAccessor { window in
secondaryPanelController.setHostWindow(isPanelVisible ? window : nil)
if isPanelVisible {
syncSecondaryPanelWindow()
}
}
)
.onAppear {
hoverCoordinator.onDismissRequest = { activation in
pluginHost.clearPanelNavigationSelection(
controlID: activation.controlID,
for: activation.pluginID
)
}
secondaryPanelController.onHostWindowDismissRequest = {
hoverCoordinator.dismissImmediately()
}
}
.animation(.easeOut(duration: 0.18), value: activeSecondaryPanelSignature)
.onChange(of: activeSecondaryPanelSignature) {
syncSecondaryPanelWindowIfVisible()
}
.onChange(of: hoverCoordinator.selectedRowFrame) {
syncSecondaryPanelWindowIfVisible()
}
.onChange(of: hoverCoordinator.activeActivation) {
syncSecondaryPanelWindowIfVisible()
}
.onReceive(pluginHost.$settingsPresentationRequestCount.dropFirst()) { _ in
if isPanelVisible {
presentSettings()
}
}
.onReceive(NotificationCenter.default.publisher(for: AppAppearancePreference.didChangeNotification)) { _ in
secondaryPanelController.applyCurrentAppearance()
}
.onChange(of: isPanelVisible) { _, isVisible in
if isVisible {
syncSecondaryPanelWindow()
} else {
hoverCoordinator.dismissImmediately()
secondaryPanelController.setHostWindow(nil)
}
}
.onDisappear {
flushDeferredActionsIfNeeded()
hoverCoordinator.dismissImmediately()
hoverCoordinator.onDismissRequest = nil
secondaryPanelController.onHostWindowDismissRequest = nil
secondaryPanelController.setHostWindow(nil)
}
}
private func syncSecondaryPanelWindowIfVisible() {
guard isPanelVisible else {
return
}
syncSecondaryPanelWindow()
}
private var content: some View {
featureList
.frame(height: visibleFeatureListHeight, alignment: .topLeading)
.frame(
width: MenuBarPanelLayout.surfaceWidth,
height: contentBodyHeight,
alignment: .topLeading
)
}
@ViewBuilder
private var featureList: some View {
ScrollView(.vertical, showsIndicators: false) {
featureCards
}
.scrollDisabled(!isFeatureListScrollable)
.background(ScrollViewScrollerVisibilityConfigurator())
}
private var featureListHeight: CGFloat {
MenuBarPanelLayout.featureListHeight(
featureContentHeight: featureContentHeight,
maximumFeatureListHeight: maximumFeatureListHeight
)
}
private var visibleFeatureListHeight: CGFloat {
min(featureListHeight, contentBodyHeight)
}
private var isFeatureListScrollable: Bool {
featureContentHeight > visibleFeatureListHeight
}
private var featureContentHeight: CGFloat {
MenuBarPanelLayout.featureContentHeight(for: pluginHost.panelItems)
}
private func presentSettings() {
onOpenSettings()
onDismiss()
}
private func handlePanelSwitchChange(_ newValue: Bool, for item: PluginPanelItem) {
switch item.menuActionBehavior {
case .keepPresented:
pluginHost.setSwitchValue(newValue, for: item.id)
case .dismissBeforeHandling:
deferredActionDispatcher.deferPanelSwitch(
pluginID: item.id,
isOn: newValue
)
onDismiss()
flushDeferredActionsAfterDismiss()
}
}
private func handleActionInvoke(
controlID: String,
for item: PluginPanelItem,
behavior: PluginMenuActionBehavior
) {
if isDiskCleanOpenDetailsAction(pluginID: item.id, controlID: controlID) {
presentDiskCleanDetails()
onDismiss()
return
}
if isLaunchControlOpenManagerAction(pluginID: item.id, controlID: controlID) {
presentLaunchControlManager()
onDismiss()
return
}
if isFanControlManagePresetsAction(pluginID: item.id, controlID: controlID) {
pluginHost.presentPluginConfiguration(pluginID: Self.fanControlPluginID)
onDismiss()
return
}
if isZshConfigOpenSettingsAction(pluginID: item.id, controlID: controlID) {
pluginHost.presentPluginConfiguration(pluginID: Self.zshConfigPluginID)
onDismiss()
return
}
switch behavior {
case .keepPresented:
pluginHost.invokePanelAction(controlID: controlID, for: item.id)
case .dismissBeforeHandling:
// Dismiss the popover before running actions that may open a new window.
deferredActionDispatcher.deferActionInvocation(
pluginID: item.id,
controlID: controlID
)
onDismiss()
flushDeferredActionsAfterDismiss()
}
}
private func flushDeferredActionsAfterDismiss() {
deferredActionDispatcher.flushAfterDismiss(
switchHandler: performDeferredPanelSwitchAction,
invocationHandler: performDeferredActionInvocation
)
}
private func flushDeferredActionsIfNeeded() {
deferredActionDispatcher.flush(
switchHandler: performDeferredPanelSwitchAction,
invocationHandler: performDeferredActionInvocation
)
}
private func performDeferredPanelSwitchAction(_ action: DeferredPanelActionDispatcher.PanelSwitchAction) {
pluginHost.setSwitchValue(
action.isOn,
for: action.pluginID
)
}
private func performDeferredActionInvocation(_ action: DeferredPanelActionDispatcher.ActionInvocation) {
if isDiskCleanOpenDetailsAction(pluginID: action.pluginID, controlID: action.controlID) {
presentDiskCleanDetails()
return
}
if isLaunchControlOpenManagerAction(pluginID: action.pluginID, controlID: action.controlID) {
presentLaunchControlManager()
return
}
if isFanControlManagePresetsAction(pluginID: action.pluginID, controlID: action.controlID) {
pluginHost.presentPluginConfiguration(pluginID: Self.fanControlPluginID)
return
}
if isZshConfigOpenSettingsAction(pluginID: action.pluginID, controlID: action.controlID) {
pluginHost.presentPluginConfiguration(pluginID: Self.zshConfigPluginID)
return
}
if isBatteryChargeLimitManageSettingsAction(pluginID: action.pluginID, controlID: action.controlID) {
pluginHost.presentPluginConfiguration(pluginID: Self.batteryChargeLimitPluginID)
return
}
pluginHost.invokePanelAction(
controlID: action.controlID,
for: action.pluginID
)
}
private func isDiskCleanOpenDetailsAction(pluginID: String, controlID: String) -> Bool {
pluginID == Self.diskCleanWindowID && controlID == Self.diskCleanOpenDetailsActionID
}
private func isLaunchControlOpenManagerAction(pluginID: String, controlID: String) -> Bool {
pluginID == Self.launchControlWindowID && controlID == Self.launchControlOpenManagerActionID
}
private func isFanControlManagePresetsAction(pluginID: String, controlID: String) -> Bool {
pluginID == Self.fanControlPluginID && controlID == Self.fanControlManagePresetsActionID
}
private func isZshConfigOpenSettingsAction(pluginID: String, controlID: String) -> Bool {
pluginID == Self.zshConfigPluginID && controlID == Self.zshConfigOpenSettingsActionID
}
private func isBatteryChargeLimitManageSettingsAction(pluginID: String, controlID: String) -> Bool {
pluginID == Self.batteryChargeLimitPluginID && controlID == Self.batteryChargeLimitManageSettingsActionID
}
private func presentDiskCleanDetails() {
onPresentDiskCleanConfiguration()
}
private func presentLaunchControlManager() {
onPresentLaunchControlConfiguration()
}
private func syncSecondaryPanelWindow() {
guard
let activation = hoverCoordinator.activeActivation,
let panelItem = pluginHost.panelItems.first(where: { $0.id == activation.pluginID }),
let panel = panelItem.detail?.secondaryPanel(
controlID: activation.controlID,
optionID: activation.optionID
),
let anchorRect = hoverCoordinator.selectedRowFrame
else {
secondaryPanelController.hide()
return
}
secondaryPanelController.show(
panel: panel,
anchorRect: anchorRect,
onSelectionChange: { controlID, optionID in
pluginHost.setPanelSelectionValue(optionID, controlID: controlID, for: panelItem.id)
},
onNavigationSelectionChange: { controlID, optionID in
pluginHost.setPanelNavigationSelectionValue(optionID, controlID: controlID, for: panelItem.id)
},
onDateChange: { controlID, date in
pluginHost.setPanelDateValue(date, controlID: controlID, for: panelItem.id)
},
onHoverChange: handleSecondaryPanelHoverChange,
onSliderChange: { controlID, value, phase in
pluginHost.setPanelSliderValue(
value,
controlID: controlID,
for: panelItem.id,
phase: phase
)
}
)
}
private func handleNavigationHoverChange(
pluginID: String,
controlID: String,
optionID: String,
isHovering: Bool
) {
if isHovering {
hoverCoordinator.hoverBegan(
pluginID: pluginID,
controlID: controlID,
optionID: optionID
)
return
}
hoverCoordinator.hoverEnded(
pluginID: pluginID,
controlID: controlID,
optionID: optionID
)
}
private func handleSecondaryPanelHoverChange(_ isHovering: Bool) {
hoverCoordinator.setPanelHovered(isHovering)
}
private var activeSecondaryPanelSignature: String? {
guard
let activation = hoverCoordinator.activeActivation,
let panelItem = pluginHost.panelItems.first(where: { $0.id == activation.pluginID }),
let panel = panelItem.detail?.secondaryPanel(
controlID: activation.controlID,
optionID: activation.optionID
)
else {
return nil
}
let controlIDs = panel.controls.map(\.id).joined(separator: ",")
return "\(activation.pluginID)|\(activation.optionID)|\(panel.title)|\(controlIDs)"
}
private var featureCards: some View {
VStack(spacing: MenuBarPanelLayout.featureRowSpacing) {
if pluginHost.panelItems.isEmpty {
PanelPluginEmptyState(
title: AppL10n.plugins("plugin.panel.empty.title", defaultValue: "暂无插件"),
systemImage: "shippingbox",
iconTint: .blue,
onInstall: {
pluginHost.presentPluginMarketplace()
},
onEnable: {
pluginHost.presentInstalledPlugins()
}
)
.frame(minHeight: MenuBarPanelLayout.emptyContentHeight)
} else {
ForEach(pluginHost.panelItems) { item in
FeatureRowView(
item: item,
indicator: pluginHost.primaryPanelIndicatorsByID[item.id],
isOn: Binding(
get: { pluginHost.isSwitchOn(for: item.id) },
set: { newValue in
handlePanelSwitchChange(newValue, for: item)
}
),
onDisclosureToggle: { isExpanded in
pluginHost.setDisclosureExpanded(isExpanded, for: item.id)
},
onSelectionChange: { controlID, optionID in
pluginHost.setPanelSelectionValue(optionID, controlID: controlID, for: item.id)
},
onNavigationSelectionChange: { controlID, optionID in
pluginHost.setPanelNavigationSelectionValue(optionID, controlID: controlID, for: item.id)
},
onNavigationHoverChange: { controlID, optionID, isHovering in
handleNavigationHoverChange(
pluginID: item.id,
controlID: controlID,
optionID: optionID,
isHovering: isHovering
)
},
onNavigationRowFrameChange: { controlID, optionID, frame in
hoverCoordinator.updateRowFrame(
frame,
for: HoverSecondaryPanelCoordinator.Activation(
pluginID: item.id,
controlID: controlID,
optionID: optionID
)
)
},
onDateChange: { controlID, date in
pluginHost.setPanelDateValue(date, controlID: controlID, for: item.id)
},
onSwitchChange: { newValue in
handlePanelSwitchChange(newValue, for: item)
},
onSliderChange: { controlID, value, phase in
pluginHost.setPanelSliderValue(
value,
controlID: controlID,
for: item.id,
phase: phase
)
},
onActionInvoke: { controlID, behavior in
handleActionInvoke(
controlID: controlID,
for: item,
behavior: behavior
)
}
)
}
}
}
.frame(width: MenuBarPanelLayout.surfaceWidth, alignment: .leading)
}
}
@MainActor
final class DeferredPanelActionDispatcher: ObservableObject {
struct PanelSwitchAction: Equatable {
let pluginID: String
let isOn: Bool
}
struct ActionInvocation: Equatable {
let pluginID: String
let controlID: String
}
private(set) var pendingPanelSwitchAction: PanelSwitchAction?
private(set) var pendingActionInvocation: ActionInvocation?
private var flushTask: Task<Void, Never>?
func deferPanelSwitch(pluginID: String, isOn: Bool) {
pendingPanelSwitchAction = PanelSwitchAction(pluginID: pluginID, isOn: isOn)
}
func deferActionInvocation(pluginID: String, controlID: String) {
pendingActionInvocation = ActionInvocation(pluginID: pluginID, controlID: controlID)
}
func flushAfterDismiss(
switchHandler: @escaping @MainActor (PanelSwitchAction) -> Void,
invocationHandler: @escaping @MainActor (ActionInvocation) -> Void
) {
guard flushTask == nil else {
return
}
flushTask = Task { @MainActor [weak self] in
await Task.yield()
self?.flush(
switchHandler: switchHandler,
invocationHandler: invocationHandler
)
}
}
func flush(
switchHandler: (PanelSwitchAction) -> Void,
invocationHandler: (ActionInvocation) -> Void
) {
flushTask?.cancel()
flushTask = nil
let panelSwitchAction = pendingPanelSwitchAction
let actionInvocation = pendingActionInvocation
pendingPanelSwitchAction = nil
pendingActionInvocation = nil
if let panelSwitchAction {
switchHandler(panelSwitchAction)
}
if let actionInvocation {
invocationHandler(actionInvocation)
}
}
}
struct FeatureRowView: View {
let item: PluginPanelItem
let indicator: PluginPrimaryPanelIndicator?
@Binding var isOn: Bool
let onDisclosureToggle: (Bool) -> Void
let onSelectionChange: (String, String) -> Void
let onNavigationSelectionChange: (String, String) -> Void
let onNavigationHoverChange: (String, String, Bool) -> Void
let onNavigationRowFrameChange: (String, String, CGRect?) -> Void
let onDateChange: (String, Date) -> Void
let onSwitchChange: (Bool) -> Void
let onSliderChange: (String, Double, PluginPanelAction.SliderPhase) -> Void
let onActionInvoke: (String, PluginMenuActionBehavior) -> Void
@State private var isHovered = false
@State private var didPushDisabledCursor = false
var body: some View {
VStack(alignment: .leading, spacing: detailToDisplay == nil ? 0 : MenuBarPanelLayout.detailSpacing) {
switch item.controlStyle {
case .switch:
rowHeader
case .disclosure:
Button {
onDisclosureToggle(!item.isExpanded)
} label: {
rowHeader
}
.buttonStyle(.plain)
.disabled(!item.isEnabled)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
case .button:
HStack(alignment: .center, spacing: FeatureRowLayout.rowSpacing) {
ZStack {
RoundedRectangle(cornerRadius: FeatureRowLayout.iconCornerRadius, style: .continuous)
.fill(Color.primary.opacity(0.08))
Image(systemName: item.iconName)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
}
.frame(width: FeatureRowLayout.iconSize, height: FeatureRowLayout.iconSize)
rowText
.frame(maxWidth: .infinity, alignment: .leading)
Button {
if let actionID = item.buttonActionID {
onActionInvoke(actionID, item.menuActionBehavior)
}
} label: {
Text(item.buttonTitle ?? AppL10n.plugins("plugin.panel.actionFallback", defaultValue: "操作"))
.font(.system(size: 11))
.foregroundStyle(.white)
.frame(minWidth: 45, minHeight: 21)
.background(item.isEnabled ? Color.accentColor : Color(NSColor.secondaryLabelColor))
.cornerRadius(15)
}
.buttonStyle(.plain)
.disabled(!item.isEnabled)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
if let detail = detailToDisplay {
PluginPanelDetailView(
detail: detail,
isOn: $isOn,
showsSecondaryPanel: false,
onSelectionChange: onSelectionChange,
onNavigationSelectionChange: onNavigationSelectionChange,
onNavigationHoverChange: onNavigationHoverChange,
onNavigationRowFrameChange: onNavigationRowFrameChange,
onDateChange: onDateChange,
onSwitchChange: onSwitchChange,
onSliderChange: onSliderChange,
onActionInvoke: onActionInvoke
)
.padding(.leading, FeatureRowLayout.detailLeadingInset)
}
}
.padding(.horizontal, FeatureRowLayout.rowHorizontalPadding)
.padding(.vertical, FeatureRowLayout.rowVerticalPadding)
.frame(maxWidth: .infinity, alignment: .leading)
.background(alignment: .center) {
RoundedRectangle(cornerRadius: MenuBarHoverStyle.cornerRadius, style: .continuous)
.inset(by: MenuBarHoverStyle.inset)
.fill(item.isEnabled && isHovered ? MenuBarHoverStyle.fill : Color.clear)
}
.contentShape(RoundedRectangle(cornerRadius: MenuBarHoverStyle.cornerRadius, style: .continuous))
.onHover { hovering in
isHovered = hovering
updateCursorForDisabledState(hovering: hovering)
}
.onChange(of: item.isEnabled) { _, _ in
updateCursorForDisabledState(hovering: isHovered)
}
.onDisappear {
resetDisabledCursorIfNeeded()
}
.help(item.helpText)
}
private var rowHeader: some View {
HStack(alignment: .center, spacing: FeatureRowLayout.rowSpacing) {
ZStack {
RoundedRectangle(cornerRadius: FeatureRowLayout.iconCornerRadius, style: .continuous)
.fill(Color.primary.opacity(0.08))
Image(systemName: item.iconName)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
}
.frame(width: FeatureRowLayout.iconSize, height: FeatureRowLayout.iconSize)
rowText
.frame(maxWidth: .infinity, alignment: .leading)
switch item.controlStyle {
case .switch:
Toggle(String(), isOn: $isOn)
.labelsHidden()
.controlSize(.small)
.toggleStyle(.switch)
.disabled(!item.isEnabled)
case .disclosure:
Image(systemName: item.isExpanded ? "chevron.down" : "chevron.right")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.secondary)
.frame(width: FeatureRowLayout.chevronSize, height: FeatureRowLayout.chevronSize)
case .button:
Image(systemName: "arrow.up.right.square")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.secondary)
.frame(width: FeatureRowLayout.chevronSize, height: FeatureRowLayout.chevronSize)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: MenuBarPanelLayout.rowHeaderHeight, alignment: .center)
.contentShape(Rectangle())
}
private var detailToDisplay: PluginPanelDetail? {
guard let detail = item.detail else {
return nil
}
if item.controlStyle == .disclosure && !item.isExpanded {
return nil
}
return detail
}
private var rowText: some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 5) {
Text(item.title)
.font(.system(size: 13, weight: .semibold))
.lineLimit(1)
if let indicator {
Label(indicator.text, systemImage: indicator.systemImage)
.font(.system(size: 8.5, weight: .semibold))
.foregroundStyle(.secondary)
.lineLimit(1)
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(Color.primary.opacity(0.07), in: Capsule())
.fixedSize(horizontal: true, vertical: false)
}
}
HStack(spacing: 3) {
Text(item.description)
.font(.system(size: 10.5, weight: .medium))
.foregroundStyle(item.descriptionTone == .error ? Color.red : .secondary)
.lineLimit(1)
.truncationMode(.tail)
.help(item.helpText)
if showsIPOverviewCopyButton {
Button {
onActionInvoke(MenuBarContent.ipOverviewCopyIPActionID, .keepPresented)
} label: {
Image(systemName: "doc.on.doc")
.font(.system(size: 8, weight: .semibold))
.foregroundStyle(.secondary)
.frame(width: 10.5, height: 10.5)
}
.buttonStyle(.plain)
.opacity(isHovered ? 1 : 0)
.help(AppL10n.plugins("plugin.panel.copyIP", defaultValue: "复制 IP"))
}
}
}
}
private var showsIPOverviewCopyButton: Bool {
item.id == MenuBarContent.ipOverviewPluginID
&& !descriptionIsError
&& Self.looksLikeIPAddress(item.description)
}
private var descriptionIsError: Bool {
switch item.descriptionTone {
case .error:
return true
case .secondary:
return false
}
}
private static func looksLikeIPAddress(_ value: String) -> Bool {
let allowedCharacters = CharacterSet(charactersIn: "0123456789abcdefABCDEF:.")
return !value.isEmpty
&& value.rangeOfCharacter(from: allowedCharacters.inverted) == nil
&& (value.contains(".") || value.contains(":"))
}
private func updateCursorForDisabledState(hovering: Bool) {
if !item.isEnabled && hovering {
if !didPushDisabledCursor {
NSCursor.operationNotAllowed.push()
didPushDisabledCursor = true
}
} else {
resetDisabledCursorIfNeeded()
}
}
private func resetDisabledCursorIfNeeded() {
if didPushDisabledCursor {
NSCursor.pop()
didPushDisabledCursor = false
}
}
}
private struct PluginPanelDetailView: View {
let detail: PluginPanelDetail
@Binding var isOn: Bool
let showsSecondaryPanel: Bool
let onSelectionChange: (String, String) -> Void
let onNavigationSelectionChange: (String, String) -> Void
let onNavigationHoverChange: (String, String, Bool) -> Void
let onNavigationRowFrameChange: (String, String, CGRect?) -> Void
let onDateChange: (String, Date) -> Void
let onSwitchChange: (Bool) -> Void
let onSliderChange: (String, Double, PluginPanelAction.SliderPhase) -> Void
let onActionInvoke: (String, PluginMenuActionBehavior) -> Void
var body: some View {
VStack(alignment: .leading, spacing: MenuBarPanelLayout.detailControlSpacing) {
ForEach(detail.primaryControls) { control in
if control.showsLeadingDivider {
Divider()
.padding(.horizontal, FeatureRowLayout.detailControlHorizontalPadding)
}
panelControl(control)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder
private func panelControl(_ control: PluginPanelControl) -> some View {
switch control.kind {
case .segmented:
Picker(
String(),
selection: Binding(
get: { control.selectedOptionID ?? "" },
set: { newValue in
onSelectionChange(control.id, newValue)
}
)
) {
ForEach(control.options) { option in
Text(option.title).tag(option.id)
}
}
.labelsHidden()
.pickerStyle(.segmented)
.frame(maxWidth: .infinity, alignment: .leading)
.disabled(!control.isEnabled)
case .datePicker:
switch control.datePickerStyle ?? .compact {
case .compact:
DatePicker(
String(),
selection: Binding(
get: { control.dateValue ?? Date() },
set: { newValue in
onDateChange(control.id, newValue)
}
),
in: (control.minimumDate ?? Date())...,
displayedComponents: control.displayedComponents ?? [.date, .hourAndMinute]
)
.labelsHidden()
.datePickerStyle(.compact)
.disabled(!control.isEnabled)
case .dateTimeCard:
DateTimeCardPicker(
selection: Binding(
get: { control.dateValue ?? Date() },
set: { newValue in
onDateChange(control.id, newValue)
}
),
minimumDate: control.minimumDate ?? Date(),
isEnabled: control.isEnabled
)
}
case .selectList:
SelectListControl(
control: control,
onSelect: { optionID in
onSelectionChange(control.id, optionID)
}
)
case .navigationList:
NavigationListControl(
control: control,
onSelect: { optionID in
onNavigationSelectionChange(control.id, optionID)
},
onHoverChange: { optionID, isHovering in
onNavigationHoverChange(control.id, optionID, isHovering)
},
onRowFrameChange: { optionID, frame in
onNavigationRowFrameChange(control.id, optionID, frame)
}
)
case .slider:
SliderControl(
control: control,
onChange: { value, phase in
onSliderChange(control.id, value, phase)
}
)
case .switchRow:
SwitchRowControl(
control: control,
isOn: $isOn,
onChange: onSwitchChange
)
case .actionRow:
ActionRowControl(
control: control,
onInvoke: {
onActionInvoke(control.id, control.actionBehavior)
}
)
}
}
}
private struct SwitchRowControl: View {
let control: PluginPanelControl
@Binding var isOn: Bool
let onChange: (Bool) -> Void
@State private var isHovered = false
var body: some View {
HStack(spacing: 8) {
if let iconName = control.actionIconSystemName {
Image(systemName: iconName)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.secondary)
.frame(width: 14, height: 14)
}
Text(control.actionTitle ?? control.sectionTitle ?? "")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.primary)
.lineLimit(1)
Spacer()
Toggle(String(), isOn: Binding(
get: { isOn },
set: { newValue in
guard control.isEnabled else { return }
onChange(newValue)
}
))
.labelsHidden()
.controlSize(.small)
.toggleStyle(.switch)
.disabled(!control.isEnabled)
}
.padding(.horizontal, FeatureRowLayout.detailControlHorizontalPadding)
.padding(.vertical, MenuBarPanelLayout.actionRowVerticalPadding)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.background(alignment: .center) {
RoundedRectangle(cornerRadius: MenuBarHoverStyle.navigationCornerRadius, style: .continuous)
.inset(by: MenuBarHoverStyle.inset)
.fill(control.isEnabled && isHovered ? MenuBarHoverStyle.fill : Color.clear)
}
.onHover { isHovered = $0 }
}
}
private struct ActionRowControl: View {
let control: PluginPanelControl
let onInvoke: () -> Void
@State private var isHovered = false
var body: some View {
Button {
guard control.isEnabled else { return }
onInvoke()
} label: {
HStack(spacing: 8) {
Image(systemName: control.actionIconSystemName ?? "arrow.up.right.square")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.secondary)
.frame(width: 14, height: 14)
Text(control.actionTitle ?? "")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.primary)
.lineLimit(1)
Spacer()
}
.padding(.horizontal, FeatureRowLayout.detailControlHorizontalPadding)
.padding(.vertical, MenuBarPanelLayout.actionRowVerticalPadding)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.background(alignment: .center) {
RoundedRectangle(cornerRadius: MenuBarHoverStyle.navigationCornerRadius, style: .continuous)
.inset(by: MenuBarHoverStyle.inset)
.fill(control.isEnabled && isHovered ? MenuBarHoverStyle.fill : Color.clear)
}
}
.buttonStyle(.plain)
.disabled(!control.isEnabled)
.onHover { isHovered = $0 }
}
}
private struct SelectListControl: View {
let control: PluginPanelControl
let onSelect: (String) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 3) {
if let title = control.sectionTitle {
Text(title)
.font(.system(size: 10.5, weight: .medium))
.foregroundStyle(.secondary)
.padding(.leading, 5)
.padding(.bottom, 1)
}
VStack(spacing: 0) {
ForEach(control.options) { option in
SelectListRow(
title: option.title,
isSelected: option.id == control.selectedOptionID,
isEnabled: control.isEnabled,
action: { onSelect(option.id) }
)
}
}
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
}
}
}
private struct SelectListRow: View {
let title: String
let isSelected: Bool
let isEnabled: Bool
let action: () -> Void
@State private var isHovered = false
var body: some View {
Button {
guard isInteractive else {
return
}
action()
} label: {
HStack(spacing: 7) {
Image(systemName: "checkmark")
.font(.system(size: 10, weight: .semibold))
.opacity(isSelected ? 1 : 0)
.frame(width: 12)
Text(title)
.font(.system(size: 11.5))
.foregroundStyle(.primary)
Spacer()
}
.padding(.horizontal, 7)
.padding(.vertical, MenuBarPanelLayout.selectRowVerticalPadding)
.contentShape(Rectangle())
.background(alignment: .center) {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.inset(by: MenuBarHoverStyle.inset)
.fill(isInteractive && isHovered ? MenuBarHoverStyle.fill : Color.clear)
}
}
.buttonStyle(.plain)
.disabled(!isEnabled)
.onHover { isHovered = $0 }
}
private var isInteractive: Bool {
isEnabled && !isSelected
}
}
private struct NavigationListControl: View {
let control: PluginPanelControl
let onSelect: (String) -> Void
let onHoverChange: (String, Bool) -> Void
let onRowFrameChange: (String, CGRect?) -> Void
var body: some View {
VStack(spacing: 0) {
ForEach(control.options) { option in
NavigationListRow(
title: option.title,
subtitle: option.subtitle,
isSelected: option.id == control.selectedOptionID,
isEnabled: control.isEnabled,
action: { onSelect(option.id) },
onHoverChange: { isHovering in
onHoverChange(option.id, isHovering)
},
onRowFrameChange: { frame in
onRowFrameChange(option.id, frame)
}
)
}
}
}
}
private struct NavigationListRow: View {
let title: String
let subtitle: String?
let isSelected: Bool
let isEnabled: Bool
let action: () -> Void
let onHoverChange: (Bool) -> Void
let onRowFrameChange: (CGRect?) -> Void
@State private var isHovered = false
var body: some View {
Button {
guard isInteractive else {
return
}
action()
} label: {
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.primary)
if let subtitle {
Text(subtitle)
.font(.system(size: 10.5, weight: .medium))
.foregroundStyle(.secondary)
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 9.5, weight: .semibold))
.foregroundStyle(.secondary)
.opacity(isSelected ? 1 : (isHovered ? 0.55 : 0.35))
}
.padding(.horizontal, FeatureRowLayout.detailControlHorizontalPadding)
.padding(.vertical, 6)
.frame(minHeight: MenuBarPanelLayout.navigationRowHeight)
.frame(maxWidth: .infinity, alignment: .leading)
.background(alignment: .center) {
RoundedRectangle(cornerRadius: MenuBarHoverStyle.navigationCornerRadius, style: .continuous)
.inset(by: MenuBarHoverStyle.inset)
.fill(backgroundFill)
}
.contentShape(RoundedRectangle(cornerRadius: MenuBarHoverStyle.navigationCornerRadius, style: .continuous))
}
.buttonStyle(.plain)
.disabled(!isEnabled)
.frame(maxWidth: .infinity, alignment: .leading)
.background {
NavigationRowFrameReader(
onFrameChange: onRowFrameChange
)
}
.onDisappear {
onHoverChange(false)
onRowFrameChange(nil)
}
.onHover { hovering in
isHovered = hovering
onHoverChange(hovering)
}
}
private var isInteractive: Bool {
isEnabled && !isSelected
}
private var backgroundFill: Color {
if isSelected {
return MenuBarHoverStyle.navigationSelectedFill
}
if isHovered && isEnabled {
return MenuBarHoverStyle.navigationFill
}
return .clear
}
}
private struct SliderControl: View {
let control: PluginPanelControl
let onChange: (Double, PluginPanelAction.SliderPhase) -> Void
@State private var localValue = 0.0
@State private var isEditing = false
@State private var isHovered = false
var body: some View {
VStack(alignment: .leading, spacing: 6) {
if control.sectionTitle != nil || control.valueLabel != nil {
HStack(alignment: .firstTextBaseline, spacing: 6) {
if let title = control.sectionTitle, !title.isEmpty {
Text(title)
.font(.system(size: 12.5, weight: .semibold))
.foregroundStyle(.primary)
.lineLimit(1)
}
Spacer(minLength: 6)
if let valueLabel = control.valueLabel {
Text(valueLabel)
.font(.system(size: 10.5, weight: .medium))
.foregroundStyle(.secondary)
}
}
}
HStack(alignment: .center, spacing: 8) {
Slider(
value: Binding(
get: { isEditing ? localValue : (control.sliderValue ?? localValue) },
set: { newValue in
let snappedValue = snappedSliderValue(for: newValue)
localValue = snappedValue
onChange(snappedValue, .changed)
}
),
in: control.sliderBounds ?? 0...1,
onEditingChanged: { isEditing in
self.isEditing = isEditing
if isEditing {
localValue = control.sliderValue ?? localValue
} else {
onChange(localValue, .ended)
}
}
)
.labelsHidden()
.disabled(!control.isEnabled)
.tint(Color(nsColor: .controlAccentColor))
.accessibilityLabel(control.sectionTitle ?? AppL10n.plugins(
"plugin.panel.displayBrightnessFallback",
defaultValue: "显示器亮度"
))
}
}
.padding(.horizontal, FeatureRowLayout.detailControlHorizontalPadding)
.padding(.vertical, MenuBarPanelLayout.sliderVerticalPadding)
.frame(maxWidth: .infinity, alignment: .leading)
.background(alignment: .center) {
RoundedRectangle(cornerRadius: MenuBarHoverStyle.navigationCornerRadius, style: .continuous)
.inset(by: MenuBarHoverStyle.inset)
.fill(control.isEnabled && isHovered ? MenuBarHoverStyle.fill : Color.clear)
}
.contentShape(RoundedRectangle(cornerRadius: MenuBarHoverStyle.navigationCornerRadius, style: .continuous))
.onHover { isHovered = $0 }
.onAppear {
localValue = control.sliderValue ?? 0
}
.onChange(of: control.sliderValue) { _, newValue in
guard !isEditing else {
return
}
localValue = newValue ?? localValue
}
}
private func brightnessGlyph(systemName: String, size: CGFloat) -> some View {
Image(systemName: systemName)
.font(.system(size: size, weight: .medium))
.foregroundStyle(.secondary)
.frame(width: size + 6, alignment: .center)
.accessibilityHidden(true)
}
private func snappedSliderValue(for value: Double) -> Double {
let bounds = control.sliderBounds ?? 0...1
let clampedValue = min(max(value, bounds.lowerBound), bounds.upperBound)
guard
let step = control.sliderStep,
step > 0
else {
return clampedValue
}
let snappedValue = (clampedValue / step).rounded() * step
return min(max(snappedValue, bounds.lowerBound), bounds.upperBound)
}
}
private struct SecondarySlidingPanel: View {
private static let cornerRadius: CGFloat = MenuBarPanelLayout.cornerRadius
let title: String
let controls: [PluginPanelControl]
let onSelectionChange: (String, String) -> Void
let onNavigationSelectionChange: (String, String) -> Void
let onDateChange: (String, Date) -> Void
let onHoverChange: (Bool) -> Void
let onSliderChange: (String, Double, PluginPanelAction.SliderPhase) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.primary)
PluginPanelDetailView(
detail: PluginPanelDetail(primaryControls: controls, secondaryPanel: nil),
isOn: .constant(false),
showsSecondaryPanel: false,
onSelectionChange: onSelectionChange,
onNavigationSelectionChange: onNavigationSelectionChange,
onNavigationHoverChange: { _, _, _ in },
onNavigationRowFrameChange: { _, _, _ in },
onDateChange: onDateChange,
onSwitchChange: { _ in },
onSliderChange: onSliderChange,
onActionInvoke: { _, _ in }
)
}
.padding(MenuBarPanelLayout.outerPadding)
.frame(maxWidth: .infinity, alignment: .leading)
.background {
PopoverMaterialBackground()
}
.clipShape(
RoundedRectangle(
cornerRadius: Self.cornerRadius,
style: .continuous
)
)
.contentShape(
RoundedRectangle(
cornerRadius: Self.cornerRadius,
style: .continuous
)
)
.onHover(perform: onHoverChange)
}
}
private final class SecondaryPanelWindow: NSPanel {
override var canBecomeKey: Bool { false }
override var canBecomeMain: Bool { false }
}
private struct PopoverMaterialBackground: NSViewRepresentable {
func makeNSView(context: Context) -> NSVisualEffectView {
let view = NSVisualEffectView()
configure(view)
return view
}
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {
configure(nsView)
}
private func configure(_ view: NSVisualEffectView) {
view.material = .popover
view.blendingMode = .behindWindow
view.state = .active
}
}
@MainActor
private final class SecondaryPanelController: ObservableObject {
// The secondary panel must remain a sibling of the MenuBarExtra popover, not a child window.
//
// Background: `NSWindow.addChildWindow(_:, ordered:)` binds parent and child key status into the
// same focus group, so the parent window does not receive `didResignKeyNotification` when the
// user clicks outside. `MenuBarExtra(.window)` dismissal, implemented by SwiftUI's private
// `WindowMenuBarExtraBehavior`, relies on the popover's `didResignKey` notification. Once this
// panel is attached as a child window, the popover never closes itself.
//
// Solution: keep it as an independent sibling NSPanel and never call `addChildWindow`. Position
// is computed directly from `anchorRect`; the level is raised above the popover; lifecycle
// cleanup is driven by the SwiftUI view's `.onDisappear` cascading to `hide()`.
//
// References:
// - MenuBarExtraAccess source, which observes `didResignKey` on `MenuBarExtraWindow`
// https://github.com/orchetect/MenuBarExtraAccess
// - Apple Feedback FB11984872: window-style MenuBarExtra cannot be closed programmatically
// - CocoaDev "HowCanChildWindowBeKey": https://cocoadev.github.io/HowCanChildWindowBeKey/
private weak var hostWindow: NSWindow?
private var panelWindow: SecondaryPanelWindow?
private var panelHostingView: NSHostingView<AnyView>?
private var hostWindowObservers: [NSObjectProtocol] = []
var onHostWindowDismissRequest: (() -> Void)?
func setHostWindow(_ window: NSWindow?) {
guard hostWindow !== window else {
return
}
removeHostWindowObservers()
hostWindow = window
guard window != nil else {
hide()
return
}
observeHostWindowIfNeeded()
}
func show(
panel: PluginPanelSecondaryPanel,
anchorRect: CGRect,
onSelectionChange: @escaping (String, String) -> Void,
onNavigationSelectionChange: @escaping (String, String) -> Void,
onDateChange: @escaping (String, Date) -> Void,
onHoverChange: @escaping (Bool) -> Void,
onSliderChange: @escaping (String, Double, PluginPanelAction.SliderPhase) -> Void
) {
guard let hostWindow else { return }
// `MenuWindowAccessor.updateNSView` can still dispatch async callbacks after `.onDisappear`,
// which may call `show()` again after `hide()`. When the popover is dismissed, `hostWindow`
// is already not visible; use that to block the race from re-showing the panel.
guard hostWindow.isVisible else { return }
let rootView = AnyView(
SecondarySlidingPanel(
title: panel.title,
controls: panel.controls,
onSelectionChange: onSelectionChange,
onNavigationSelectionChange: onNavigationSelectionChange,
onDateChange: onDateChange,
onHoverChange: onHoverChange,
onSliderChange: onSliderChange
)
.frame(width: MenuBarPanelLayout.secondaryPanelWidth)
)
let panelWindow = panelWindow ?? makePanel()
// Reuse one NSHostingView. Rebuilding `contentView` on every `show()` destroys the SwiftUI
// Button hit between mouseDown and mouseUp, dropping clicks such as display-resolution
// selections. Updating `rootView` in place preserves pressed state and hover tracking.
let hostingView: NSHostingView<AnyView>
if let existing = panelHostingView, panelWindow.contentView === existing {
existing.rootView = rootView
hostingView = existing
} else {
let newHosting = NSHostingView(rootView: rootView)
panelWindow.contentView = newHosting
panelHostingView = newHosting
hostingView = newHosting
}
applyCurrentAppearance()
let fittingSize = hostingView.fittingSize
let width = MenuBarPanelLayout.secondaryPanelWidth
let height = max(fittingSize.height, MenuBarPanelLayout.secondaryPanelMinimumHeight)
let origin = CGPoint(
x: anchorRect.maxX + MenuBarPanelLayout.panelSpacing,
y: anchorRect.maxY - height
)
let frame = CGRect(origin: origin, size: CGSize(width: width, height: height))
panelWindow.setFrame(frame, display: true)
// Align the panel level to `hostWindow.level + 1` at runtime so it stays above the popover.
// The MenuBarExtra popover level is a private SwiftUI implementation detail.
panelWindow.level = NSWindow.Level(rawValue: hostWindow.level.rawValue + 1)
panelWindow.orderFrontRegardless()
self.panelWindow = panelWindow
}
func applyCurrentAppearance() {
let preference = AppAppearancePreference.stored()
preference.apply(to: panelWindow)
preference.apply(to: panelHostingView)
}
func hide() {
guard let panelWindow else { return }
panelWindow.orderOut(nil)
self.panelWindow = nil
self.panelHostingView = nil
}
private func makePanel() -> SecondaryPanelWindow {
let panel = SecondaryPanelWindow(
contentRect: .zero,
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
panel.isFloatingPanel = true
MenuBarPanelWindowRegistry.markSecondaryPanel(panel)
// Keep this false. For an LSUIElement menu-bar app, the app is often inactive while
// MenuBarExtra is open, but the menu remains interactive. If `hidesOnDeactivate` is enabled,
// the panel hides immediately after showing, or can end up with `isVisible == true` while no
// pixels are on screen. Panel lifetime is driven by MenuBarContent's `onDisappear` and
// `syncSecondaryPanelWindow`.
panel.hidesOnDeactivate = false
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = true
panel.isMovable = false
panel.isReleasedWhenClosed = false
AppAppearancePreference.stored().apply(to: panel)
return panel
}
private func observeHostWindowIfNeeded() {
guard let hostWindow else {
return
}
let notificationCenter = NotificationCenter.default
hostWindowObservers = [
notificationCenter.addObserver(
forName: NSWindow.didResignKeyNotification,
object: hostWindow,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.hide()
self?.onHostWindowDismissRequest?()
}
},
notificationCenter.addObserver(
forName: NSWindow.willCloseNotification,
object: hostWindow,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.hide()
self?.onHostWindowDismissRequest?()
}
}
]
}
private func removeHostWindowObservers() {
let notificationCenter = NotificationCenter.default
hostWindowObservers.forEach(notificationCenter.removeObserver)
hostWindowObservers.removeAll()
}
}
private struct MenuWindowAccessor: NSViewRepresentable {
let onWindowChange: (NSWindow?) -> Void
func makeNSView(context: Context) -> NSView {
let view = NSView(frame: .zero)
DispatchQueue.main.async {
onWindowChange(view.window)
}
return view
}
// Report the window on every re-render. The parent calls `syncSecondaryPanelWindow()`, which is
// the fallback refresh for cases where screen-resolution changes or short popover visibility
// gaps cause `onChange` hooks to miss a needed `show()`. This must be paired with NSHostingView
// reuse in `SecondaryPanelController.show()`; otherwise contentView rebuilds between mouseDown
// and mouseUp can drop button clicks.
func updateNSView(_ nsView: NSView, context: Context) {
DispatchQueue.main.async {
onWindowChange(nsView.window)
}
}
}
private struct NavigationRowFrameReader: NSViewRepresentable {
let onFrameChange: (CGRect?) -> Void
func makeNSView(context: Context) -> NSView {
let view = NSView(frame: .zero)
DispatchQueue.main.async {
updateFrame(for: view)
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
DispatchQueue.main.async {
updateFrame(for: nsView)
}
}
private func updateFrame(for view: NSView) {
guard let window = view.window else {
onFrameChange(nil)
return
}
let rectInWindow = view.convert(view.bounds, to: nil)
let rectOnScreen = window.convertToScreen(rectInWindow)
onFrameChange(rectOnScreen)
}
}
struct ScrollViewScrollerVisibilityConfigurator: NSViewRepresentable {
func makeNSView(context: Context) -> NSView {
let view = NSView(frame: .zero)
DispatchQueue.main.async {
configureScrollView(containing: view, remainingRetries: 4)
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
DispatchQueue.main.async {
configureScrollView(containing: nsView, remainingRetries: 4)
}
}
private func configureScrollView(containing view: NSView, remainingRetries: Int) {
guard let scrollView = nearestScrollView(from: view) else {
guard remainingRetries > 0 else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) {
configureScrollView(containing: view, remainingRetries: remainingRetries - 1)
}
return
}
scrollView.hasVerticalScroller = false
scrollView.hasHorizontalScroller = false
scrollView.autohidesScrollers = true
scrollView.scrollerStyle = .overlay
scrollView.automaticallyAdjustsContentInsets = false
let zeroInsets = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
scrollView.contentInsets = zeroInsets
scrollView.scrollerInsets = zeroInsets
}
private func nearestScrollView(from view: NSView) -> NSScrollView? {
if let scrollView = view as? NSScrollView {
return scrollView
}
if let scrollView = view.enclosingScrollView {
return scrollView
}
var currentView = view.superview
while let candidate = currentView {
if let scrollView = candidate as? NSScrollView {
return scrollView
}
currentView = candidate.superview
}
return nil
}
}
private struct DateTimeCardPicker: View {
@Binding var selection: Date
let minimumDate: Date
let isEnabled: Bool
var body: some View {
DatePicker(
String(),
selection: Binding(
get: { sanitizedDate(selection) },
set: { newValue in
selection = sanitizedDate(newValue)
}
),
in: minimumDate...,
displayedComponents: [.date, .hourAndMinute]
)
.labelsHidden()
.datePickerStyle(.compact)
.frame(maxWidth: .infinity, alignment: .leading)
.disabled(!isEnabled)
.environment(\.locale, .current)
.frame(maxWidth: .infinity, alignment: .leading)
.opacity(isEnabled ? 1 : 0.6)
}
private func sanitizedDate(_ candidate: Date) -> Date {
max(candidate, minimumDate)
}
}