179 lines
7.4 KiB
Swift
179 lines
7.4 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
extension StatusItemController {
|
|
func refreshMenuCardHeights(in menu: NSMenu) {
|
|
let width = self.renderedMenuWidth(for: menu)
|
|
for item in menu.items {
|
|
if let view = item.view as? PersistentRefreshMenuView {
|
|
guard abs(view.frame.width - width) > 0.5 else { continue }
|
|
view.applySize(width: width, height: PersistentRefreshRowMetrics.defaults.rowHeight)
|
|
continue
|
|
}
|
|
guard let view = item.view, view is any MenuCardMeasuring else { continue }
|
|
guard abs(view.frame.width - width) > 0.5 else { continue }
|
|
let id = item.representedObject as? String ?? "menuCard"
|
|
let scope = self.menuProvider(for: menu)?.rawValue ?? id
|
|
let height = self.cachedMenuCardHeight(for: id, scope: scope, width: width) {
|
|
self.menuCardHeight(for: view, width: width)
|
|
}
|
|
view.frame = NSRect(
|
|
origin: .zero,
|
|
size: NSSize(width: width, height: height))
|
|
}
|
|
}
|
|
|
|
func makeMenuCardItem<CardContent: View>(
|
|
_ view: CardContent,
|
|
id: String,
|
|
width: CGFloat,
|
|
heightCacheScope: String? = nil,
|
|
heightCacheFingerprint: String? = nil,
|
|
submenu: NSMenu? = nil,
|
|
submenuIndicatorAlignment: Alignment = .topTrailing,
|
|
submenuIndicatorTopPadding: CGFloat = 8,
|
|
containsInteractiveControls: Bool = false,
|
|
usesGPUSelection: Bool = false,
|
|
onClick: (() -> Void)? = nil) -> NSMenuItem
|
|
{
|
|
let allowsMenuHighlight = submenu != nil || onClick != nil
|
|
if !self.menuCardRenderingEnabledForController {
|
|
let item = NSMenuItem()
|
|
item.isEnabled = allowsMenuHighlight
|
|
item.representedObject = id
|
|
item.submenu = submenu
|
|
if submenu != nil {
|
|
item.target = self
|
|
item.action = #selector(self.menuCardNoOp(_:))
|
|
}
|
|
return item
|
|
}
|
|
|
|
if usesGPUSelection {
|
|
// Selection is painted by AppKit/GPU, so the SwiftUI content is pinned to its normal
|
|
// appearance via a `highlightState` that is never flipped; these rows skip hosting-view
|
|
// recycling because the recycler is typed to `MenuCardItemHostingView`.
|
|
let interactiveRegionStore = MenuCardInteractiveRegionStore()
|
|
let wrapped = MenuCardSectionContainerView(
|
|
highlightState: MenuCardHighlightState(),
|
|
showsSubmenuIndicator: submenu != nil,
|
|
submenuIndicatorAlignment: submenuIndicatorAlignment,
|
|
submenuIndicatorTopPadding: submenuIndicatorTopPadding,
|
|
refreshMonitor: self.menuCardRefreshMonitor,
|
|
interactiveRegionStore: interactiveRegionStore)
|
|
{
|
|
view
|
|
}
|
|
let gpuHosting = GPUSelectionHostingView(
|
|
rootView: wrapped,
|
|
allowsMenuHighlight: allowsMenuHighlight,
|
|
containsInteractiveControls: containsInteractiveControls,
|
|
interactiveRegionStore: interactiveRegionStore,
|
|
onClick: onClick)
|
|
let gpuHeight = self.cachedMenuCardHeight(
|
|
for: id,
|
|
scope: heightCacheScope ?? id,
|
|
width: width,
|
|
fingerprint: heightCacheFingerprint)
|
|
{
|
|
self.menuCardHeight(for: gpuHosting, width: width)
|
|
}
|
|
gpuHosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: gpuHeight))
|
|
return self.makeMenuCardNSMenuItem(
|
|
hosting: gpuHosting,
|
|
id: id,
|
|
submenu: submenu,
|
|
isEnabled: allowsMenuHighlight || containsInteractiveControls)
|
|
}
|
|
|
|
let hosting: MenuCardItemHostingView<MenuCardSectionContainerView<CardContent>>
|
|
if let recycled = self.takeRecyclableMenuCardView(
|
|
for: id,
|
|
as: MenuCardItemHostingView<MenuCardSectionContainerView<CardContent>>.self)
|
|
{
|
|
let wrapped = MenuCardSectionContainerView(
|
|
highlightState: recycled.highlightState,
|
|
showsSubmenuIndicator: submenu != nil,
|
|
submenuIndicatorAlignment: submenuIndicatorAlignment,
|
|
submenuIndicatorTopPadding: submenuIndicatorTopPadding,
|
|
refreshMonitor: self.menuCardRefreshMonitor,
|
|
interactiveRegionStore: recycled.interactiveRegionStore)
|
|
{
|
|
view
|
|
}
|
|
recycled.prepareForReuse(
|
|
rootView: wrapped,
|
|
allowsMenuHighlight: allowsMenuHighlight,
|
|
containsInteractiveControls: containsInteractiveControls,
|
|
onClick: onClick)
|
|
hosting = recycled
|
|
} else {
|
|
let highlightState = MenuCardHighlightState()
|
|
let interactiveRegionStore = MenuCardInteractiveRegionStore()
|
|
let wrapped = MenuCardSectionContainerView(
|
|
highlightState: highlightState,
|
|
showsSubmenuIndicator: submenu != nil,
|
|
submenuIndicatorAlignment: submenuIndicatorAlignment,
|
|
submenuIndicatorTopPadding: submenuIndicatorTopPadding,
|
|
refreshMonitor: self.menuCardRefreshMonitor,
|
|
interactiveRegionStore: interactiveRegionStore)
|
|
{
|
|
view
|
|
}
|
|
hosting = MenuCardItemHostingView(
|
|
rootView: wrapped,
|
|
highlightState: highlightState,
|
|
allowsMenuHighlight: allowsMenuHighlight,
|
|
containsInteractiveControls: containsInteractiveControls,
|
|
interactiveRegionStore: interactiveRegionStore,
|
|
onClick: onClick)
|
|
}
|
|
let height = self.cachedMenuCardHeight(
|
|
for: id,
|
|
scope: heightCacheScope ?? id,
|
|
width: width,
|
|
fingerprint: heightCacheFingerprint)
|
|
{
|
|
self.menuCardHeight(for: hosting, width: width)
|
|
}
|
|
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height))
|
|
return self.makeMenuCardNSMenuItem(
|
|
hosting: hosting,
|
|
id: id,
|
|
submenu: submenu,
|
|
isEnabled: allowsMenuHighlight || containsInteractiveControls)
|
|
}
|
|
|
|
/// Wraps a measured hosting view in the `NSMenuItem` the menu installs, wiring submenu routing.
|
|
private func makeMenuCardNSMenuItem(
|
|
hosting: NSView,
|
|
id: String,
|
|
submenu: NSMenu?,
|
|
isEnabled: Bool) -> NSMenuItem
|
|
{
|
|
let item = NSMenuItem()
|
|
item.view = hosting
|
|
item.isEnabled = isEnabled
|
|
item.representedObject = id
|
|
item.submenu = submenu
|
|
if submenu != nil {
|
|
item.target = self
|
|
item.action = #selector(self.menuCardNoOp(_:))
|
|
}
|
|
return item
|
|
}
|
|
|
|
private func menuCardHeight(for view: NSView, width: CGFloat) -> CGFloat {
|
|
let basePadding: CGFloat = 6
|
|
let descenderSafety: CGFloat = 1
|
|
|
|
if let measured = view as? MenuCardMeasuring {
|
|
return max(1, ceil(measured.measuredHeight(width: width) + basePadding + descenderSafety))
|
|
}
|
|
|
|
view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: 1))
|
|
let fitted = view.fittingSize
|
|
return max(1, ceil(fitted.height + basePadding + descenderSafety))
|
|
}
|
|
}
|