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

737 lines
26 KiB
Swift

import AppKit
import Combine
import SwiftUI
import MacToolsPluginKit
// MARK: - SwiftUI wrapper
private enum MenuBarHiddenLayoutStripMetrics {
static let minItemWidth: CGFloat = 14
static let maxItemWidth: CGFloat = 240
static let minItemHeight: CGFloat = 18
static let spacing: CGFloat = 4
static let horizontalInset: CGFloat = 8
static let verticalInset: CGFloat = 4
static let rowSpacing: CGFloat = 4
static let singleRowHeight: CGFloat = 48
static let heightEpsilon: CGFloat = 0.5
}
struct MenuBarHiddenLayoutStrip: NSViewRepresentable {
let section: MenuBarHiddenSection
let items: [MenuBarItem]
let iconCache: MenuBarHiddenIconCache
let controller: MenuBarHiddenController
@Binding var measuredHeight: CGFloat
func makeNSView(context _: Context) -> MenuBarHiddenStripPaddingNSView {
let view = MenuBarHiddenStripPaddingNSView(
section: section,
items: items,
iconCache: iconCache,
controller: controller
)
view.onHeightChange = updateMeasuredHeight(_:)
return view
}
func updateNSView(_ view: MenuBarHiddenStripPaddingNSView, context _: Context) {
view.onHeightChange = updateMeasuredHeight(_:)
view.update(items: items, iconCache: iconCache)
}
func sizeThatFits(
_ proposal: ProposedViewSize,
nsView: MenuBarHiddenStripPaddingNSView,
context _: Context
) -> CGSize? {
let width = proposal.width ?? nsView.bounds.width
let height = nsView.fittingHeight(forWidth: width)
DispatchQueue.main.async {
updateMeasuredHeight(height)
}
if let proposedWidth = proposal.width {
return CGSize(width: proposedWidth, height: height)
}
return CGSize(width: nsView.bounds.width, height: height)
}
private func updateMeasuredHeight(_ height: CGFloat) {
guard abs(measuredHeight - height) > MenuBarHiddenLayoutStripMetrics.heightEpsilon else { return }
measuredHeight = height
}
}
// MARK: - Drag receiving padding view
final class MenuBarHiddenStripPaddingNSView: NSView {
private let container: MenuBarHiddenStripContainerNSView
private var isStabilizing = false
private var lastPublishedHeight: CGFloat = 0
var onHeightChange: ((CGFloat) -> Void)?
init(
section: MenuBarHiddenSection,
items: [MenuBarItem],
iconCache: MenuBarHiddenIconCache,
controller: MenuBarHiddenController
) {
self.container = MenuBarHiddenStripContainerNSView(
section: section,
items: items,
iconCache: iconCache,
controller: controller
)
super.init(frame: .zero)
translatesAutoresizingMaskIntoConstraints = false
addSubview(container)
registerForDraggedTypes([.menuBarHiddenItem])
NSLayoutConstraint.activate([
container.leadingAnchor.constraint(equalTo: leadingAnchor),
container.trailingAnchor.constraint(equalTo: trailingAnchor),
container.topAnchor.constraint(equalTo: topAnchor),
container.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
@available(*, unavailable)
required init?(coder _: NSCoder) { fatalError() }
func update(items: [MenuBarItem], iconCache: MenuBarHiddenIconCache) {
container.update(items: items, iconCache: iconCache)
publishFittingHeight()
}
override var intrinsicContentSize: NSSize {
NSSize(width: NSView.noIntrinsicMetric, height: fittingHeight(forWidth: bounds.width))
}
override func setFrameSize(_ newSize: NSSize) {
let oldWidth = bounds.width
super.setFrameSize(newSize)
if oldWidth != newSize.width {
invalidateIntrinsicContentSize()
publishFittingHeight()
}
}
fileprivate func fittingHeight(forWidth width: CGFloat) -> CGFloat {
container.fittingHeight(forWidth: width)
}
fileprivate func containerContentDidChange() {
invalidateIntrinsicContentSize()
needsLayout = true
publishFittingHeight()
}
private func publishFittingHeight() {
let height = fittingHeight(forWidth: bounds.width)
guard abs(lastPublishedHeight - height) > MenuBarHiddenLayoutStripMetrics.heightEpsilon else { return }
lastPublishedHeight = height
DispatchQueue.main.async { [weak self] in
self?.onHeightChange?(height)
}
}
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
guard !isStabilizing else { return [] }
return container.updateItemViewsForDrag(with: sender, phase: .entered)
}
override func draggingExited(_ sender: NSDraggingInfo?) {
guard !isStabilizing, let sender else { return }
container.updateItemViewsForDrag(with: sender, phase: .exited)
}
override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation {
guard !isStabilizing else { return [] }
return container.updateItemViewsForDrag(with: sender, phase: .updated)
}
override func draggingEnded(_ sender: NSDraggingInfo) {
guard !isStabilizing else { return }
container.updateItemViewsForDrag(with: sender, phase: .ended)
}
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
guard let draggingSource = sender.draggingSource as? MenuBarHiddenItemNSView else {
container.canSetItemViews = true
return false
}
let sourceContainer = draggingSource.oldContainerInfo?.container
guard container.canAccept(draggingSource) else {
if container.section != .visible, draggingSource.item.isHostApplicationIcon {
provideAlertForVisibleOnlyAppIcon().runModal()
}
container.updateItemViewsForDrag(with: sender, phase: .exited)
draggingSource.hasContainer = false
container.canSetItemViews = true
return false
}
var willMove = false
if let index = container.itemViews.firstIndex(of: draggingSource) {
if sourceContainer === container,
draggingSource.oldContainerInfo?.index == index
{
container.canSetItemViews = true
sourceContainer?.canSetItemViews = true
return false
}
let item = draggingSource.item
if let rightItem = container.nearestItem(toRightOf: index) {
willMove = true
draggingSource.hasPendingMove = true
move(item: item, to: .before(rightItem.tag), sourceContainer: sourceContainer)
} else if let leftItem = container.nearestItem(toLeftOf: index) {
willMove = true
draggingSource.hasPendingMove = true
move(item: item, to: .after(leftItem.tag), sourceContainer: sourceContainer)
} else {
willMove = true
draggingSource.hasPendingMove = true
move(item: item, to: .end, sourceContainer: sourceContainer)
}
}
if !willMove {
container.canSetItemViews = true
}
return true
}
private func provideAlertForVisibleOnlyAppIcon() -> NSAlert {
let localization = container.controller?.localization ?? PluginLocalization(bundle: .main)
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = localization.string(
"alert.hostIconCannotHide.title",
defaultValue: "MacTools 图标不能隐藏"
)
alert.informativeText = localization.string(
"alert.hostIconCannotHide.message",
defaultValue: "应用本身的菜单栏图标必须保留在显示区域。"
)
return alert
}
private func move(
item: MenuBarItem,
to placement: MenuBarHiddenMovePlacement,
sourceContainer: MenuBarHiddenStripContainerNSView?
) {
guard !isStabilizing else { return }
isStabilizing = true
container.alphaValue = 0.6
container.controller?.moveItem(id: item.tag, to: container.section, placement: placement)
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(900))
self.isStabilizing = false
self.container.alphaValue = 1
self.container.canSetItemViews = true
sourceContainer?.canSetItemViews = true
}
}
override func accessibilityChildren() -> [Any]? {
container.itemViews
}
}
// MARK: - Strip container
final class MenuBarHiddenStripContainerNSView: NSView {
enum DraggingPhase {
case entered
case exited
case updated
case ended
}
let section: MenuBarHiddenSection
weak var controller: MenuBarHiddenController?
private var iconCache: MenuBarHiddenIconCache
fileprivate var itemViews: [MenuBarHiddenItemNSView] = [] {
didSet { layoutItemViews(oldViews: oldValue) }
}
private var items: [MenuBarItem] = []
var canSetItemViews = true {
didSet {
guard canSetItemViews, !oldValue else { return }
rebuildItemViews()
}
}
private struct LayoutRow {
let range: Range<Int>
let height: CGFloat
}
init(
section: MenuBarHiddenSection,
items: [MenuBarItem],
iconCache: MenuBarHiddenIconCache,
controller: MenuBarHiddenController
) {
self.section = section
self.controller = controller
self.iconCache = iconCache
super.init(frame: .zero)
unregisterDraggedTypes()
translatesAutoresizingMaskIntoConstraints = false
update(items: items, iconCache: iconCache)
}
@available(*, unavailable)
required init?(coder _: NSCoder) { fatalError() }
func update(items: [MenuBarItem], iconCache: MenuBarHiddenIconCache) {
self.items = items
self.iconCache = iconCache
guard canSetItemViews else { return }
rebuildItemViews()
}
private func rebuildItemViews() {
let newViews = items.map { item in
itemViews.first(where: { $0.item == item }) ?? MenuBarHiddenItemNSView(item: item, iconCache: iconCache)
}
itemViews = newViews
}
private func layoutItemViews(oldViews: [MenuBarHiddenItemNSView]? = nil) {
let oldViews = oldViews ?? itemViews
for view in oldViews where !itemViews.contains(view) {
view.removeFromSuperview()
view.hasContainer = false
}
for view in itemViews where view.superview !== self {
addSubview(view)
view.stripView = self
view.hasContainer = true
}
invalidateIntrinsicContentSize()
(superview as? MenuBarHiddenStripPaddingNSView)?.containerContentDidChange()
needsLayout = true
}
override func layout() {
super.layout()
let rows = layoutRows(forWidth: bounds.width)
guard !rows.isEmpty else { return }
let contentHeight = contentHeight(for: rows)
var rowTop = ((bounds.height + contentHeight) / 2) - MenuBarHiddenLayoutStripMetrics.verticalInset
for row in rows {
var x = MenuBarHiddenLayoutStripMetrics.horizontalInset
for index in row.range {
let view = itemViews[index]
let preferredSize = view.preferredSize.integralCeil
let y = rowTop - row.height + ((row.height - preferredSize.height) / 2)
view.frame = CGRect(
x: x.rounded(.down),
y: y.rounded(.down),
width: preferredSize.width,
height: preferredSize.height
)
x += preferredSize.width + MenuBarHiddenLayoutStripMetrics.spacing
}
rowTop -= row.height + MenuBarHiddenLayoutStripMetrics.rowSpacing
}
}
override var intrinsicContentSize: NSSize {
NSSize(width: NSView.noIntrinsicMetric, height: fittingHeight(forWidth: bounds.width))
}
override func setFrameSize(_ newSize: NSSize) {
let oldWidth = bounds.width
super.setFrameSize(newSize)
if oldWidth != newSize.width {
invalidateIntrinsicContentSize()
(superview as? MenuBarHiddenStripPaddingNSView)?.containerContentDidChange()
}
}
fileprivate func itemPreferredSizeDidChange(_ itemView: MenuBarHiddenItemNSView) {
guard itemViews.contains(itemView) else { return }
invalidateIntrinsicContentSize()
(superview as? MenuBarHiddenStripPaddingNSView)?.containerContentDidChange()
needsLayout = true
}
fileprivate func canAccept(_ source: MenuBarHiddenItemNSView) -> Bool {
guard controller?.permissions.canManageItems ?? false else { return false }
guard source.isEnabled else { return false }
return !MenuBarHiddenLayoutPolicy.shouldRejectMoveToSection(item: source.item, section: section)
}
fileprivate func reinsert(_ view: MenuBarHiddenItemNSView, at index: Int) {
var next = itemViews
next.removeAll { $0 === view }
next.insert(view, at: min(index, next.count))
itemViews = next
}
@discardableResult
func updateItemViewsForDrag(with draggingInfo: NSDraggingInfo, phase: DraggingPhase) -> NSDragOperation {
guard let sourceView = draggingInfo.draggingSource as? MenuBarHiddenItemNSView else {
return []
}
switch phase {
case .entered:
guard canAccept(sourceView) else { return [] }
return updateItemViewsForDrag(with: draggingInfo, phase: .updated)
case .exited:
if let sourceIndex = itemViews.firstIndex(of: sourceView) {
itemViews.remove(at: sourceIndex)
}
return .move
case .updated:
guard canAccept(sourceView) else { return [] }
if sourceView.oldContainerInfo == nil,
let sourceIndex = itemViews.firstIndex(of: sourceView)
{
sourceView.oldContainerInfo = (self, sourceIndex)
}
guard itemViews.contains(where: { $0 !== sourceView && $0.isEnabled }) else {
if !itemViews.contains(sourceView) {
itemViews.insert(sourceView, at: 0)
}
return .move
}
let draggingLocation = convert(draggingInfo.draggingLocation, from: nil)
guard
let destinationView = itemView(nearestTo: draggingLocation, excluding: sourceView),
destinationView !== sourceView,
destinationView.isEnabled,
destinationView.layer?.animationKeys() == nil,
let destinationIndex = itemViews.firstIndex(of: destinationView)
else {
return .move
}
if !isNear(destinationView, draggingLocation: draggingLocation),
sourceView.oldContainerInfo?.container === self
{
return .move
}
if let sourceIndex = itemViews.firstIndex(of: sourceView) {
var targetIndex = destinationIndex
if destinationIndex > sourceIndex {
targetIndex += 1
}
itemViews.move(fromOffsets: [sourceIndex], toOffset: targetIndex)
} else {
itemViews.insert(sourceView, at: destinationIndex)
}
return .move
case .ended:
return .move
}
}
fileprivate func nearestItem(toRightOf index: Int) -> MenuBarItem? {
guard itemViews.indices.contains(index + 1) else { return nil }
for candidateIndex in (index + 1) ..< itemViews.count {
let candidate = itemViews[candidateIndex]
if candidate.isEnabled {
return candidate.item
}
}
return nil
}
fileprivate func nearestItem(toLeftOf index: Int) -> MenuBarItem? {
guard itemViews.indices.contains(index - 1) else { return nil }
for candidateIndex in stride(from: index - 1, through: 0, by: -1) {
let candidate = itemViews[candidateIndex]
if candidate.isEnabled {
return candidate.item
}
}
return nil
}
private func itemView(
nearestTo point: CGPoint,
excluding source: MenuBarHiddenItemNSView
) -> MenuBarHiddenItemNSView? {
let candidates = itemViews.filter { $0 !== source }
guard !candidates.isEmpty else { return nil }
let rowCandidates = candidates.filter {
point.y >= $0.frame.minY - MenuBarHiddenLayoutStripMetrics.rowSpacing / 2
&& point.y <= $0.frame.maxY + MenuBarHiddenLayoutStripMetrics.rowSpacing / 2
}
let effectiveCandidates = rowCandidates.isEmpty ? candidates : rowCandidates
return effectiveCandidates.min { lhs, rhs in
let lhsDistance = hypot(lhs.frame.midX - point.x, lhs.frame.midY - point.y)
let rhsDistance = hypot(rhs.frame.midX - point.x, rhs.frame.midY - point.y)
return lhsDistance < rhsDistance
}
}
private func isNear(_ view: MenuBarHiddenItemNSView, draggingLocation: CGPoint) -> Bool {
let horizontalRange = (view.frame.midX - view.frame.width / 2) ... (view.frame.midX + view.frame.width / 2)
let verticalRange = (view.frame.midY - view.frame.height / 2 - MenuBarHiddenLayoutStripMetrics.rowSpacing)
... (view.frame.midY + view.frame.height / 2 + MenuBarHiddenLayoutStripMetrics.rowSpacing)
return horizontalRange.contains(draggingLocation.x) && verticalRange.contains(draggingLocation.y)
}
fileprivate func fittingHeight(forWidth width: CGFloat) -> CGFloat {
let rows = layoutRows(forWidth: width)
guard !rows.isEmpty else { return MenuBarHiddenLayoutStripMetrics.singleRowHeight }
guard width > 0 else { return MenuBarHiddenLayoutStripMetrics.singleRowHeight }
return max(MenuBarHiddenLayoutStripMetrics.singleRowHeight, contentHeight(for: rows)).rounded(.up)
}
private func contentHeight(for rows: [LayoutRow]) -> CGFloat {
guard !rows.isEmpty else { return MenuBarHiddenLayoutStripMetrics.singleRowHeight }
let rowsHeight = rows.reduce(CGFloat(0)) { $0 + $1.height }
let spacing = CGFloat(max(0, rows.count - 1)) * MenuBarHiddenLayoutStripMetrics.rowSpacing
return (MenuBarHiddenLayoutStripMetrics.verticalInset * 2) + rowsHeight + spacing
}
private func layoutRows(forWidth width: CGFloat) -> [LayoutRow] {
guard !itemViews.isEmpty else { return [] }
let availableWidth = max(
MenuBarHiddenLayoutStripMetrics.minItemWidth,
width - (MenuBarHiddenLayoutStripMetrics.horizontalInset * 2)
)
var rows: [LayoutRow] = []
var rowStart = itemViews.startIndex
var rowWidth: CGFloat = 0
var rowHeight = MenuBarHiddenLayoutStripMetrics.minItemHeight
for index in itemViews.indices {
let itemSize = itemViews[index].preferredSize.integralCeil
let widthWithSpacing = rowWidth == 0
? itemSize.width
: rowWidth + MenuBarHiddenLayoutStripMetrics.spacing + itemSize.width
if rowWidth > 0, widthWithSpacing > availableWidth {
rows.append(LayoutRow(range: rowStart ..< index, height: rowHeight))
rowStart = index
rowWidth = 0
rowHeight = 0
}
if rowWidth > 0 {
rowWidth += MenuBarHiddenLayoutStripMetrics.spacing
}
rowWidth += itemSize.width
rowHeight = max(rowHeight, itemSize.height)
}
rows.append(LayoutRow(range: rowStart ..< itemViews.endIndex, height: rowHeight))
return rows
}
}
// MARK: - Individual item tile
final class MenuBarHiddenItemNSView: NSView, NSDraggingSource {
let item: MenuBarItem
private var iconCache: MenuBarHiddenIconCache
weak var stripView: MenuBarHiddenStripContainerNSView?
var oldContainerInfo: (container: MenuBarHiddenStripContainerNSView, index: Int)?
var hasContainer = false
var isDraggingPlaceholder = false {
didSet { needsDisplay = true }
}
var isEnabled = true {
didSet { needsDisplay = true }
}
var hasPendingMove = false
private var cachedImage: MenuBarHiddenIconCache.CapturedImage? {
didSet {
let oldSize = preferredSize(for: oldValue)
let newSize = preferredSize(for: cachedImage)
guard !MenuBarHiddenIconCache.CapturedImage.isVisuallyEqual(oldValue, cachedImage) else { return }
setFrameSize(newSize)
if oldSize != newSize {
stripView?.itemPreferredSizeDidChange(self)
}
needsDisplay = true
}
}
private var cancellables = Set<AnyCancellable>()
init(item: MenuBarItem, iconCache: MenuBarHiddenIconCache) {
self.item = item
self.iconCache = iconCache
self.cachedImage = iconCache.image(for: item.tag)
super.init(frame: CGRect(origin: .zero, size: Self.preferredSize(for: item, image: iconCache.image(for: item.tag))))
unregisterDraggedTypes()
toolTip = item.displayName
setAccessibilityLabel(item.displayName)
isEnabled = item.isMovable
configureCancellables()
}
@available(*, unavailable)
required init?(coder _: NSCoder) { fatalError() }
private func configureCancellables() {
let tag = item.tag
iconCache.$images
.map { [weak iconCache] _ in iconCache?.image(for: tag) }
.removeDuplicates(by: MenuBarHiddenIconCache.CapturedImage.isVisuallyEqual)
.receive(on: DispatchQueue.main)
.sink { [weak self] image in
self?.cachedImage = image
}
.store(in: &cancellables)
}
override func draw(_: NSRect) {
guard !isDraggingPlaceholder else { return }
if let image = cachedImage?.nsImage {
image.draw(
in: bounds,
from: .zero,
operation: .sourceOver,
fraction: isEnabled ? 1.0 : 0.67
)
}
}
var preferredSize: CGSize {
preferredSize(for: cachedImage)
}
override func mouseDragged(with event: NSEvent) {
guard stripView?.controller?.permissions.canManageItems ?? false else { return }
guard isEnabled else {
provideAlertForDisabledItem().runModal()
return
}
let pasteboardItem = NSPasteboardItem()
pasteboardItem.setData(Data(), forType: .menuBarHiddenItem)
let draggingItem = NSDraggingItem(pasteboardWriter: pasteboardItem)
draggingItem.setDraggingFrame(bounds, contents: draggingImage())
beginDraggingSession(with: [draggingItem], event: event, source: self)
}
func draggingSession(
_: NSDraggingSession,
sourceOperationMaskFor _: NSDraggingContext
) -> NSDragOperation {
.move
}
func draggingSession(_ session: NSDraggingSession, willBeginAt _: NSPoint) {
if let container = superview as? MenuBarHiddenStripContainerNSView {
if oldContainerInfo == nil,
let sourceIndex = container.itemViews.firstIndex(of: self)
{
oldContainerInfo = (container, sourceIndex)
}
container.canSetItemViews = false
}
session.animatesToStartingPositionsOnCancelOrFail = false
Task { @MainActor in
self.isDraggingPlaceholder = true
}
}
func draggingSession(_: NSDraggingSession, endedAt _: NSPoint, operation _: NSDragOperation) {
let sourceContainer = oldContainerInfo?.container
defer {
oldContainerInfo = nil
hasPendingMove = false
}
isDraggingPlaceholder = false
if !hasContainer {
guard let (container, index) = oldContainerInfo else { return }
container.reinsert(self, at: index)
}
if !hasPendingMove {
sourceContainer?.canSetItemViews = true
if sourceContainer == nil,
let container = superview as? MenuBarHiddenStripContainerNSView
{
container.canSetItemViews = true
}
}
}
private func provideAlertForDisabledItem() -> NSAlert {
let localization = stripView?.controller?.localization ?? PluginLocalization(bundle: .main)
let alert = NSAlert()
alert.messageText = localization.string(
"alert.itemCannotMove.title",
defaultValue: "菜单栏图标不可移动"
)
alert.informativeText = localization.format(
"alert.itemCannotMove.message",
defaultValue: "macOS 不允许移动“%@”。",
item.displayName
)
return alert
}
private func draggingImage() -> NSImage? {
cachedImage?.nsImage ?? bitmapImage()
}
private func bitmapImage() -> NSImage? {
guard let rep = bitmapImageRepForCachingDisplay(in: bounds) else { return nil }
cacheDisplay(in: bounds, to: rep)
let image = NSImage(size: bounds.size)
image.addRepresentation(rep)
return image
}
private func preferredSize(for image: MenuBarHiddenIconCache.CapturedImage?) -> CGSize {
Self.preferredSize(for: item, image: image)
}
private static func preferredSize(
for item: MenuBarItem,
image: MenuBarHiddenIconCache.CapturedImage?
) -> CGSize {
if let image {
return image.scaledSize
}
let width = min(
MenuBarHiddenLayoutStripMetrics.maxItemWidth,
max(MenuBarHiddenLayoutStripMetrics.minItemWidth, item.bounds.width)
)
let height = max(item.bounds.height, MenuBarHiddenLayoutStripMetrics.minItemHeight)
return CGSize(width: width, height: height)
}
}
// MARK: - Pasteboard type
extension NSPasteboard.PasteboardType {
static let menuBarHiddenItem = NSPasteboard.PasteboardType(MenuBarHiddenConstants.itemPasteboardType)
}
private extension CGSize {
var integralCeil: CGSize {
CGSize(width: width.rounded(.up), height: height.rounded(.up))
}
}