330 lines
11 KiB
Swift
330 lines
11 KiB
Swift
import AppKit
|
|
import Combine
|
|
import SwiftUI
|
|
import MacToolsPluginKit
|
|
|
|
// MARK: - MenuBarHiddenPopupPanel
|
|
|
|
@MainActor
|
|
final class MenuBarHiddenPopupPanel: NSPanel {
|
|
private weak var controller: MenuBarHiddenController?
|
|
private let panelSize = NSSize(width: 400, height: 120)
|
|
|
|
init(controller: MenuBarHiddenController) {
|
|
self.controller = controller
|
|
super.init(
|
|
contentRect: NSRect(origin: .zero, size: NSSize(width: 400, height: 120)),
|
|
styleMask: [.borderless, .nonactivatingPanel],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
isFloatingPanel = true
|
|
level = .floating
|
|
backgroundColor = .clear
|
|
isOpaque = false
|
|
hasShadow = true
|
|
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
|
|
|
let effect = NSVisualEffectView(frame: NSRect(origin: .zero, size: panelSize))
|
|
effect.material = .popover
|
|
effect.blendingMode = .behindWindow
|
|
effect.state = .active
|
|
effect.maskImage = Self.roundedMaskImage(size: panelSize, cornerRadius: 14)
|
|
|
|
let hosting = NSHostingView(rootView: MenuBarHiddenPopupView(controller: controller))
|
|
hosting.frame = effect.bounds
|
|
hosting.autoresizingMask = [.width, .height]
|
|
effect.addSubview(hosting)
|
|
|
|
contentView = effect
|
|
setContentSize(panelSize)
|
|
}
|
|
|
|
override var canBecomeKey: Bool { true }
|
|
override var canBecomeMain: Bool { false }
|
|
|
|
func show(anchor: NSRect?) {
|
|
position(anchor: anchor)
|
|
orderFrontRegardless()
|
|
makeKey()
|
|
}
|
|
|
|
override func cancelOperation(_: Any?) {
|
|
orderOut(nil)
|
|
controller?.closePopup()
|
|
}
|
|
|
|
override func resignKey() {
|
|
super.resignKey()
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self, self.isVisible else { return }
|
|
self.orderOut(nil)
|
|
self.controller?.closePopup()
|
|
}
|
|
}
|
|
|
|
private func position(anchor: NSRect?) {
|
|
let matchingScreen = anchor.flatMap { rect in
|
|
NSScreen.screens.first(where: { $0.frame.intersects(rect) })
|
|
}
|
|
guard let screen = matchingScreen ?? NSScreen.main else { return }
|
|
|
|
let x: CGFloat
|
|
let y: CGFloat
|
|
if let anchor {
|
|
x = max(
|
|
screen.visibleFrame.minX + 8,
|
|
min(anchor.midX - panelSize.width / 2, screen.visibleFrame.maxX - panelSize.width - 8)
|
|
)
|
|
y = max(
|
|
screen.visibleFrame.minY + 8,
|
|
min(anchor.minY - panelSize.height - 8, screen.visibleFrame.maxY - panelSize.height - 8)
|
|
)
|
|
} else {
|
|
x = screen.visibleFrame.midX - panelSize.width / 2
|
|
y = screen.visibleFrame.maxY - panelSize.height - 12
|
|
}
|
|
setFrame(NSRect(origin: CGPoint(x: x, y: y), size: panelSize), display: true)
|
|
}
|
|
|
|
private static func roundedMaskImage(size: NSSize, cornerRadius: CGFloat) -> NSImage {
|
|
let image = NSImage(size: size, flipped: false) { _ in
|
|
NSColor.black.setFill()
|
|
NSBezierPath(
|
|
roundedRect: NSRect(origin: .zero, size: size),
|
|
xRadius: cornerRadius,
|
|
yRadius: cornerRadius
|
|
).fill()
|
|
return true
|
|
}
|
|
image.capInsets = NSEdgeInsets(
|
|
top: cornerRadius, left: cornerRadius,
|
|
bottom: cornerRadius, right: cornerRadius
|
|
)
|
|
image.resizingMode = .stretch
|
|
return image
|
|
}
|
|
}
|
|
|
|
// MARK: - Popup contents
|
|
|
|
private struct MenuBarHiddenPopupView: View {
|
|
@ObservedObject var controller: MenuBarHiddenController
|
|
private var localization: PluginLocalization { controller.localization }
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack {
|
|
Label(
|
|
localization.string("popup.title", defaultValue: "隐藏图标"),
|
|
systemImage: "menubar.arrow.up.rectangle"
|
|
)
|
|
.font(.headline)
|
|
Spacer()
|
|
}
|
|
|
|
if controller.snapshot.hiddenItems.isEmpty {
|
|
emptyView
|
|
} else {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 6) {
|
|
ForEach(controller.snapshot.hiddenItems) { item in
|
|
MenuBarHiddenPopupItem(
|
|
item: item,
|
|
iconCache: controller.manager.iconCache,
|
|
onLeftClick: {
|
|
controller.clickItemAfterPopupCloses(item, button: .left)
|
|
},
|
|
onRightClick: {
|
|
controller.clickItemAfterPopupCloses(item, button: .right)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
.padding(.vertical, 2)
|
|
}
|
|
}
|
|
}
|
|
.padding(14)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
}
|
|
|
|
private var emptyView: some View {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "checkmark.circle").foregroundStyle(.secondary)
|
|
Text(localization.string("popup.empty", defaultValue: "没有隐藏的菜单栏图标"))
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
|
|
extension NSPanel {
|
|
@MainActor
|
|
func waitUntilClosed(timeout: Duration = .milliseconds(200)) async {
|
|
guard isVisible else { return }
|
|
|
|
let deadline = ContinuousClock.now + timeout
|
|
while isVisible, ContinuousClock.now < deadline {
|
|
try? await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Per-item popup cell (left/right click)
|
|
|
|
private struct MenuBarHiddenPopupItem: NSViewRepresentable {
|
|
let item: MenuBarItem
|
|
let iconCache: MenuBarHiddenIconCache
|
|
let onLeftClick: () -> Void
|
|
let onRightClick: () -> Void
|
|
|
|
func makeNSView(context _: Context) -> MenuBarHiddenPopupItemNSView {
|
|
MenuBarHiddenPopupItemNSView(
|
|
item: item,
|
|
iconCache: iconCache,
|
|
onLeftClick: onLeftClick,
|
|
onRightClick: onRightClick
|
|
)
|
|
}
|
|
|
|
func updateNSView(_ nsView: MenuBarHiddenPopupItemNSView, context _: Context) {
|
|
nsView.update(
|
|
item: item,
|
|
iconCache: iconCache,
|
|
onLeftClick: onLeftClick,
|
|
onRightClick: onRightClick
|
|
)
|
|
}
|
|
}
|
|
|
|
final class MenuBarHiddenPopupItemNSView: NSView {
|
|
private var item: MenuBarItem
|
|
private var iconCache: MenuBarHiddenIconCache
|
|
private var onLeftClick: () -> Void
|
|
private var onRightClick: () -> Void
|
|
private var cachedImage: MenuBarHiddenIconCache.CapturedImage? {
|
|
didSet {
|
|
guard !MenuBarHiddenIconCache.CapturedImage.isVisuallyEqual(oldValue, cachedImage) else { return }
|
|
needsDisplay = true
|
|
}
|
|
}
|
|
private var isHovered = false
|
|
private var trackingArea: NSTrackingArea?
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
private static let itemSize: CGFloat = 38
|
|
|
|
init(
|
|
item: MenuBarItem,
|
|
iconCache: MenuBarHiddenIconCache,
|
|
onLeftClick: @escaping () -> Void,
|
|
onRightClick: @escaping () -> Void
|
|
) {
|
|
self.item = item
|
|
self.iconCache = iconCache
|
|
self.onLeftClick = onLeftClick
|
|
self.onRightClick = onRightClick
|
|
self.cachedImage = iconCache.image(for: item.tag)
|
|
super.init(frame: CGRect(x: 0, y: 0, width: 48, height: 48))
|
|
toolTip = item.displayName
|
|
configureCancellables()
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder _: NSCoder) { fatalError() }
|
|
|
|
func update(
|
|
item: MenuBarItem,
|
|
iconCache: MenuBarHiddenIconCache,
|
|
onLeftClick: @escaping () -> Void,
|
|
onRightClick: @escaping () -> Void
|
|
) {
|
|
self.item = item
|
|
self.iconCache = iconCache
|
|
self.onLeftClick = onLeftClick
|
|
self.onRightClick = onRightClick
|
|
toolTip = item.displayName
|
|
cachedImage = iconCache.image(for: item.tag)
|
|
configureCancellables()
|
|
needsDisplay = true
|
|
}
|
|
|
|
private func configureCancellables() {
|
|
cancellables.removeAll()
|
|
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 var intrinsicContentSize: NSSize { NSSize(width: 48, height: 48) }
|
|
|
|
override func updateTrackingAreas() {
|
|
super.updateTrackingAreas()
|
|
if let ta = trackingArea { removeTrackingArea(ta) }
|
|
let ta = NSTrackingArea(
|
|
rect: bounds,
|
|
options: [.mouseEnteredAndExited, .activeAlways],
|
|
owner: self,
|
|
userInfo: nil
|
|
)
|
|
addTrackingArea(ta)
|
|
trackingArea = ta
|
|
}
|
|
|
|
override func mouseEntered(with _: NSEvent) {
|
|
isHovered = true; needsDisplay = true
|
|
}
|
|
|
|
override func mouseExited(with _: NSEvent) {
|
|
isHovered = false; needsDisplay = true
|
|
}
|
|
|
|
override func mouseUp(with _: NSEvent) { onLeftClick() }
|
|
override func rightMouseUp(with _: NSEvent) { onRightClick() }
|
|
|
|
override func draw(_: NSRect) {
|
|
let bg: NSColor = isHovered
|
|
? NSColor.selectedContentBackgroundColor.withAlphaComponent(0.18)
|
|
: .clear
|
|
bg.setFill()
|
|
NSBezierPath(roundedRect: bounds.insetBy(dx: 2, dy: 2), xRadius: 6, yRadius: 6).fill()
|
|
|
|
let iconSize = Self.itemSize
|
|
let iconRect = CGRect(
|
|
x: (bounds.width - iconSize) / 2,
|
|
y: (bounds.height - iconSize) / 2,
|
|
width: iconSize,
|
|
height: iconSize
|
|
)
|
|
|
|
if let image = cachedImage?.nsImage {
|
|
image.draw(
|
|
in: aspectFitRect(imageSize: image.size, in: iconRect),
|
|
from: .zero,
|
|
operation: .sourceOver,
|
|
fraction: 1.0
|
|
)
|
|
}
|
|
}
|
|
|
|
private func aspectFitRect(imageSize: CGSize, in rect: CGRect) -> CGRect {
|
|
guard imageSize.width > 0, imageSize.height > 0 else { return .zero }
|
|
let scale = min(rect.width / imageSize.width, rect.height / imageSize.height)
|
|
let size = CGSize(width: imageSize.width * scale, height: imageSize.height * scale)
|
|
return CGRect(
|
|
x: rect.midX - size.width / 2,
|
|
y: rect.midY - size.height / 2,
|
|
width: size.width,
|
|
height: size.height
|
|
)
|
|
}
|
|
}
|