From 9db2b7e789d84fff6705ffd519f8e93e40fe7b61 Mon Sep 17 00:00:00 2001 From: ysicing Date: Thu, 1 Oct 2026 17:34:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(menubar):=20=E4=BF=AE=E5=A4=8D=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E5=81=B6=E5=8F=91=E6=97=A0=E6=B3=95=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/XStatsUI/AppKit/StatusPanel.swift | 63 +++++---- .../StatusPanelLifecycleTests.swift | 127 ++++++++++++++++++ 2 files changed, 166 insertions(+), 24 deletions(-) create mode 100644 Packages/XStatsKit/Tests/XStatsUITests/StatusPanelLifecycleTests.swift diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/StatusPanel.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/StatusPanel.swift index 78a903d..48493b1 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/StatusPanel.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/StatusPanel.swift @@ -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 } } diff --git a/Packages/XStatsKit/Tests/XStatsUITests/StatusPanelLifecycleTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/StatusPanelLifecycleTests.swift new file mode 100644 index 0000000..61480c4 --- /dev/null +++ b/Packages/XStatsKit/Tests/XStatsUITests/StatusPanelLifecycleTests.swift @@ -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 }) + } +}