Files

203 lines
9.3 KiB
Swift

import AppKit
import SwiftUI
/// 菜单栏下方的浮动详情弹窗。点击外部、按 Esc 或切换应用时关闭。
/// 高度按内容自动计算,屏幕放得下就不出现滚动。
@MainActor
final class StatusPanel: NSPanel {
var onVisibilityChange: ((Bool) -> Void)?
/// 开发调试用:固定面板,不因点击外部或失去焦点而收起
var isPinned = false
private var globalMonitor: Any?
private var localMonitor: Any?
private var anchor: NSRect = .zero
private weak var anchorScreen: NSScreen?
private var lastDismissal = Date.distantPast
private var dismissedByAnchorMouseDown = false
/// 失焦回调可能晚于下一次打开;旧轮次不能关闭重新打开的窗口。
private var presentationGeneration: UInt = 0
private var heightRefreshPending = false
private var liveContentHeight: CGFloat?
/// 面板隐藏时不保留 SwiftUI 视图,避免后台随数据刷新重绘
private var makeContent: (() -> NSView)?
/// 平铺布局(不含滚动容器)的视图,只用来测量内容的自然高度
private let makeMeasuringContent: () -> NSView
private let width: CGFloat
/// 内容再少也不低于这个高度;合并模式的状态总览项目少时很矮,用更小的下限
private let minHeight: CGFloat
private let maxHeight: CGFloat?
init<Content: View, Measuring: View>(width: CGFloat, minHeight: CGFloat = DS.Size.panelMinHeight,
maxHeight: CGFloat? = nil,
content: @escaping () -> Content, measuring: @escaping () -> Measuring) {
self.width = width
self.minHeight = minHeight
self.maxHeight = maxHeight
makeMeasuringContent = { NSHostingView(rootView: measuring()) }
super.init(contentRect: NSRect(x: 0, y: 0, width: width, height: minHeight),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: true)
// 保留正在显示的视图状态;几何回调报告绝对高度,下一轮合并更新窗口。
makeContent = { [weak self] in
NSHostingView(rootView: content().environment(\.reportPopoverHeight, PopoverHeightReporter { height in
self?.updateContentHeight(height)
}))
}
isFloatingPanel = true
level = .statusBar
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient, .ignoresCycle]
isOpaque = false
backgroundColor = .clear
hasShadow = true
hidesOnDeactivate = false
isReleasedWhenClosed = false
animationBehavior = .utilityWindow
isMovable = false
}
override var canBecomeKey: Bool { true }
func toggle(below anchor: NSRect, on screen: NSScreen?) {
if isVisible {
dismiss()
} else if !dismissedByAnchorMouseDown || Date().timeIntervalSince(lastDismissal) > 0.3 {
// 面板打开时点击菜单栏图标:按下时面板已因失焦关闭,松开时不应再次打开
present(below: anchor, on: screen)
} else {
// 只消费这一次 mouse-up;下一次独立点击不必等满冷却时间。
dismissedByAnchorMouseDown = false
}
}
func present(below anchor: NSRect, on screen: NSScreen?) {
self.anchor = anchor
anchorScreen = screen
liveContentHeight = nil
guard let content = makeContent?() else { return }
presentationGeneration &+= 1
dismissedByAnchorMouseDown = false
setFrame(targetFrame(), display: false)
content.frame = NSRect(origin: .zero, size: frame.size)
contentView = PanelContainer.make(containing: content, cornerRadius: DS.Radius.xl)
// 可见性不能依赖透明度动画是否被调度;非激活面板始终以完整透明度显示。
// 窗口过渡沿用 animationBehavior,避免快速切换或繁忙布局留下透明窗口。
alphaValue = 1
makeKeyAndOrderFront(nil)
installMonitors()
onVisibilityChange?(true)
}
/// 内容区块变化后重新计算高度,保持顶部对齐。
/// 直接设置而不做窗口动画:动画期间 SwiftUI 会逐帧重排,看起来像抖动
func refreshHeight() {
guard isVisible else { return }
let frame = targetFrame()
guard frame != self.frame else { return }
setFrame(frame, display: true)
invalidateShadow()
}
/// 保留最后一条绝对高度,不在 SwiftUI 布局回调内同步 setFrame。
/// 高度来自当前视图,不能用新建视图的默认 @State 覆盖用户正在编辑或筛选的内容。
func updateContentHeight(_ height: CGFloat) {
guard height.isFinite, height > 0 else { return }
guard liveContentHeight != height else { return }
liveContentHeight = height
guard !heightRefreshPending else { return }
heightRefreshPending = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.heightRefreshPending = false
self.refreshHeight()
}
}
func dismiss(triggeredBy event: NSEvent? = nil) {
guard isVisible, !isPinned else { return }
presentationGeneration &+= 1
lastDismissal = Date()
// 图标 mouse-down 引起的关闭才短暂拦截 mouse-up,避免同一次点击立刻重开。
// 点击外部、Esc 和程序化关闭不阻挡下一次打开。
let point = event.map { $0.window?.convertPoint(toScreen: $0.locationInWindow) ?? $0.locationInWindow }
dismissedByAnchorMouseDown = event?.type == .leftMouseDown && point.map(anchor.contains) == true
removeMonitors()
// 关闭立即生效;异步淡出不仅会晚到清理内容,还可能把新窗口的透明度改回 0。
// 沿用窗口原生动画,不为短暂的关闭过渡维护额外状态与取消机制。
orderOut(nil)
contentView = nil
alphaValue = 1
onVisibilityChange?(false)
}
override func resignKey() {
let generation = presentationGeneration
let event = NSApp.currentEvent
super.resignKey()
// 弹出菜单时面板也会失去 key,稍后确认没有其他 key 窗口再关闭
DispatchQueue.main.async { [weak self] in
MainActor.assumeIsolated {
guard let self, self.presentationGeneration == generation,
self.isVisible, !self.isKeyWindow, self.attachedSheet == nil else { return }
if NSApp.keyWindow?.level == .popUpMenu { return }
self.dismiss(triggeredBy: event)
}
}
}
// MARK: 尺寸
private func targetFrame() -> NSRect {
let visible = (anchorScreen ?? NSScreen.main)?.visibleFrame ?? .zero
let natural = liveContentHeight ?? makeMeasuringContent().fittingSize.height
let preferredHeight = min(max(natural, minHeight), maxHeight ?? .greatestFiniteMagnitude)
return Self.constrainedFrame(anchor: anchor, visible: visible,
preferredSize: NSSize(width: width, height: preferredHeight))
}
/// 显示器可用区始终优先于首选尺寸,窄屏和低分辨率下不能让最小高度把面板挤出屏幕。
static func constrainedFrame(anchor: NSRect, visible: NSRect, preferredSize: NSSize) -> NSRect {
let margin = DS.Space.s2
let top = min(anchor.minY - DS.Size.panelGap, visible.maxY)
let height = min(preferredSize.height, max(1, top - visible.minY - margin))
let fittedWidth = min(preferredSize.width, max(1, visible.width - 2 * margin))
var x = anchor.midX - fittedWidth / 2
x = min(max(x, visible.minX + margin), visible.maxX - fittedWidth - margin)
return NSRect(x: x, y: top - height, width: fittedWidth, height: height)
}
// MARK: 事件
private func installMonitors() {
removeMonitors()
globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in
MainActor.assumeIsolated { self?.dismiss(triggeredBy: event) }
}
localMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .leftMouseDown]) { [weak self] event in
// Esc
if event.type == .keyDown, event.keyCode == 53, event.window === self {
MainActor.assumeIsolated { self?.dismiss(triggeredBy: event) }
return nil
}
// 本应用的图标点击不会进入 global monitor;在按下时记录关闭原因,
// 不处理弹窗内的点击,避免影响下拉菜单、文本选择等原有交互。
if event.type == .leftMouseDown, let self, event.window !== self {
let point = event.window?.convertPoint(toScreen: event.locationInWindow) ?? event.locationInWindow
if self.anchor.contains(point) { self.dismiss(triggeredBy: event) }
}
return event
}
}
private func removeMonitors() {
if let globalMonitor { NSEvent.removeMonitor(globalMonitor) }
if let localMonitor { NSEvent.removeMonitor(localMonitor) }
globalMonitor = nil
localMonitor = nil
}
}