fix(menubar): 修复弹窗偶发无法显示

This commit is contained in:
ysicing committed 2026-10-01 17:34:27 +08:00
1 parent c5ac1630e9
commit 9db2b7e789
2 files changed
+166 -24

No files matched your search

@@ -14,6 +14,9 @@ final class StatusPanel: NSPanel {
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?
@@ -60,9 +63,12 @@ final class StatusPanel: NSPanel {
func toggle(below anchor: NSRect, on screen: NSScreen?) {
if isVisible {
dismiss()
} else if Date().timeIntervalSince(lastDismissal) > 0.3 {
} else if !dismissedByAnchorMouseDown || Date().timeIntervalSince(lastDismissal) > 0.3 {
// 面板打开时点击菜单栏图标:按下时面板已因失焦关闭,松开时不应再次打开
present(below: anchor, on: screen)
} else {
// 只消费这一次 mouse-up;下一次独立点击不必等满冷却时间。
dismissedByAnchorMouseDown = false
}
}
@@ -72,16 +78,16 @@ final class StatusPanel: NSPanel {
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)
alphaValue = 0
// 可见性不能依赖透明度动画是否被调度;非激活面板始终以完整透明度显示。
// 窗口过渡沿用 animationBehavior,避免快速切换或繁忙布局留下透明窗口。
alphaValue = 1
makeKeyAndOrderFront(nil)
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.12
animator().alphaValue = 1
}
installMonitors()
onVisibilityChange?(true)
}
@@ -111,31 +117,34 @@ final class StatusPanel: NSPanel {
}
}
func dismiss() {
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()
NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.1
animator().alphaValue = 0
}, completionHandler: { [weak self] in
MainActor.assumeIsolated {
self?.orderOut(nil)
self?.contentView = nil
self?.alphaValue = 1
}
})
// 关闭立即生效;异步淡出不仅会晚到清理内容,还可能把新窗口的透明度改回 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.isVisible, !self.isKeyWindow, self.attachedSheet == nil else { return }
guard let self, self.presentationGeneration == generation,
self.isVisible, !self.isKeyWindow, self.attachedSheet == nil else { return }
if NSApp.keyWindow?.level == .popUpMenu { return }
self.dismiss()
self.dismiss(triggeredBy: event)
}
}
}
@@ -165,15 +174,21 @@ final class StatusPanel: NSPanel {
private func installMonitors() {
removeMonitors()
globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in
MainActor.assumeIsolated { self?.dismiss() }
globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in
MainActor.assumeIsolated { self?.dismiss(triggeredBy: event) }
}
localMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { [weak self] event in
localMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .leftMouseDown]) { [weak self] event in
// Esc
if event.keyCode == 53, event.window === self {
MainActor.assumeIsolated { self?.dismiss() }
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
}
}
@@ -0,0 +1,127 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import AppKit
import Foundation
import SwiftUI
import Testing
@testable import XStatsUI
@MainActor
struct StatusPanelLifecycleTests {
@Test func reopeningImmediatelyAfterDismissalKeepsTheNewContentVisible() async throws {
let screen = try #require(NSScreen.main)
let anchor = NSRect(x: screen.visibleFrame.midX, y: screen.visibleFrame.maxY, width: 20, height: 20)
let panel = StatusPanel(width: 200, minHeight: 100) {
Color.clear.frame(width: 200, height: 100)
} measuring: {
Color.clear.frame(width: 200, height: 100)
}
// 固定面板只排除其他并行窗口用例引发的失焦,显式关闭仍以正常状态执行。
panel.isPinned = true
defer { panel.isPinned = false; panel.dismiss(); panel.close() }
panel.present(below: anchor, on: screen)
try await Task.sleep(for: .milliseconds(200))
panel.isPinned = false
panel.dismiss()
#expect(!panel.isVisible)
panel.isPinned = true
panel.present(below: anchor, on: screen)
#expect(panel.isVisible)
let newContent = try #require(panel.contentView)
try await Task.sleep(for: .milliseconds(250))
#expect(panel.isVisible)
#expect(panel.contentView === newContent)
#expect(panel.alphaValue == 1)
}
@Test func anOutsideDismissalDoesNotBlockTheNextToggle() async throws {
let screen = try #require(NSScreen.main)
let anchor = NSRect(x: screen.visibleFrame.midX, y: screen.visibleFrame.maxY, width: 20, height: 20)
let panel = StatusPanel(width: 200, minHeight: 100) {
Color.clear.frame(width: 200, height: 100)
} measuring: {
Color.clear.frame(width: 200, height: 100)
}
panel.isPinned = true
defer { panel.isPinned = false; panel.dismiss(); panel.close() }
panel.present(below: anchor, on: screen)
try await Task.sleep(for: .milliseconds(200))
panel.isPinned = false
panel.dismiss()
// 已隐藏但仍处于旧的 300ms 冷却区间,正常的新打开请求必须生效。
panel.isPinned = true
panel.toggle(below: anchor, on: screen)
#expect(panel.isVisible)
}
@Test func clickingTheSameAnchorClosesWithoutReopening() async throws {
let screen = try #require(NSScreen.main)
let anchor = NSRect(x: screen.visibleFrame.midX, y: screen.visibleFrame.maxY, width: 20, height: 20)
let panel = StatusPanel(width: 200, minHeight: 100) {
Color.clear.frame(width: 200, height: 100)
} measuring: {
Color.clear.frame(width: 200, height: 100)
}
panel.isPinned = true
defer { panel.isPinned = false; panel.dismiss(); panel.close() }
panel.present(below: anchor, on: screen)
let mouseDown = try #require(NSEvent.mouseEvent(with: .leftMouseDown,
location: NSPoint(x: anchor.midX, y: anchor.midY), modifierFlags: [],
timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: 0, context: nil,
eventNumber: 1, clickCount: 1, pressure: 1))
panel.isPinned = false
panel.dismiss(triggeredBy: mouseDown)
panel.isPinned = true
// mouse-down 已关闭弹窗,同一次点击的 mouse-up 不能再把它打开。
panel.toggle(below: anchor, on: screen)
#expect(!panel.isVisible)
// 抑制只消费这一次 mouse-up,紧接着的新点击仍应立即打开。
panel.toggle(below: anchor, on: screen)
#expect(panel.isVisible)
}
@Test func repeatedDismissalNotifiesOnlyOnce() throws {
let screen = try #require(NSScreen.main)
let panel = StatusPanel(width: 200, minHeight: 100) {
Color.clear.frame(width: 200, height: 100)
} measuring: {
Color.clear.frame(width: 200, height: 100)
}
var visibility: [Bool] = []
panel.onVisibilityChange = { visibility.append($0) }
panel.isPinned = true
defer { panel.isPinned = false; panel.dismiss(); panel.close() }
panel.present(below: NSRect(x: screen.visibleFrame.midX, y: screen.visibleFrame.maxY,
width: 20, height: 20), on: screen)
panel.isPinned = false
panel.dismiss()
panel.dismiss()
#expect(visibility == [true, false])
}
@Test func repeatedOpenAndCloseReleasesTheContent() async throws {
let screen = try #require(NSScreen.main)
let anchor = NSRect(x: screen.visibleFrame.midX, y: screen.visibleFrame.maxY, width: 20, height: 20)
let panel = StatusPanel(width: 200, minHeight: 100) {
Color.clear.frame(width: 200, height: 100)
} measuring: {
Color.clear.frame(width: 200, height: 100)
}
var visibility: [Bool] = []
panel.onVisibilityChange = { visibility.append($0) }
defer { panel.isPinned = false; panel.dismiss(); panel.close() }
for _ in 0..<20 {
panel.isPinned = true
panel.toggle(below: anchor, on: screen)
#expect(panel.isVisible)
#expect(panel.contentView != nil)
panel.isPinned = false
panel.dismiss()
}
// 关闭应当同步释放内容,不能等下一次主线程动画回调。
#expect(!panel.isVisible)
#expect(panel.contentView == nil)
#expect(visibility == Array(repeating: [true, false], count: 20).flatMap { $0 })
}
}