From 55990b910e643c261df788e273c6f1e4e5ba2558 Mon Sep 17 00:00:00 2001 From: hellolio Date: Sat, 24 Oct 2026 21:28:46 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=E7=81=B5=E5=8A=A8=E5=B2=9B:=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E5=BC=B9=E5=87=BA/=E6=94=B6=E8=B5=B7=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E4=BB=8E=E5=88=98=E6=B5=B7=E5=BC=B9=E7=B0=A7=E5=B1=95?= =?UTF-8?q?=E5=BC=80/=E7=BC=A9=E5=9B=9E(iOS=20=E9=A3=8E=E6=A0=BC=E5=BC=B9?= =?UTF-8?q?=E7=B0=A7,=E6=89=93=E6=96=AD=E6=97=A0=E7=BC=9D=E7=BB=AD?= =?UTF-8?q?=E6=8E=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- PITFALLS_DEV.md | 5 + .../Services/NotchHotspotController.swift | 27 ++- Zeroflow/Views/NotchPanelView.swift | 210 ++++++++++++++++-- 4 files changed, 209 insertions(+), 35 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2aabe7f..6123960 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ SwiftUI 视图 + AppKit/CG/AX(含私有 API dlsym 桥)服务层 + UserDefaults/A - **截图链**:`GlobalHotkeyManager`(Carbon 热键,受 `screenshotEnabled` 总开关控制)→ `CaptureCoordinator`(权限→遮罩→捕获→编辑页,`isActive` 防重入)→ `ScreenCapture`(ScreenCaptureKit,按 displayID 缓存 `SCContentFilter`)→ `CaptureOverlayView`(选区遮罩)→ `EditorView`(编辑页)。 - **窗口管理族**(切换器与 Dock 预览共用):`WindowList`(CGWindowList 枚举 + 幽灵过滤 + 窗口级 MRU + 无窗口 app 占位)→ `PhantomWindowDetector`(幽灵判定,对齐 AltTab)/ `WindowActivityTracker`(AX 焦点维护 MRU)/ `WindowThumbnailer`(SkyLight 抓缩略图,能截最小化/离屏)/ `WindowActivator`(跨 Space 前台激活)/ `WindowOps`(卡片操作);消费方 `CommandTabSwitcher`(⌘⇥ 面板)与 `DockHoverPreviewController` + `DockPreviewPanel`。`CGSWindowServer` 是上述私有 API 的 dlsym 桥,启动需预热。 - **Dock**:`DockClickMinimizer`(单击最小化),开关变化经通知启停 CGEventTap。 -- **灵动岛**:`NotchHotspotController`(listen-only CGEventTap + idle→pending→visible 状态机;由公开 API 推导刘海热区:`safeAreaInsets.top` + `auxiliaryTopLeftArea`/`auxiliaryTopRightArea` 间隙)→ `NotchPanel`(非激活 NSPanel,固定深色 HUD,宽:双模块取设置最大宽(`notchMaxWidth` 420–1200)/单模块按内容收缩(下限 420×120);高:内容驱动、上限设置最大高(`notchMaxHeight` 240–1000),日历日程列表装不下在剩余空间滚动(文字不缩),日程加载/切换后自适应重测面板高度(refit),系统资源卡拉伸至驱动卡高度、行均匀分布,仅固定部分超限才整体缩小;各模块包圆角半透明卡片背景,双卡片各占一半宽;顶边贴刘海下缘,圆角 16pt≈屏幕圆角(无公开 API);背景=液态玻璃 NSGlassEffectView(macOS 26+,系统默认外观不染色)/旧系统磨砂;无内容模块时显示「添加显示内容」引导跳设置)→ `NotchCalendarViewModel` → `CalendarService`(EventKit,macOS 14 全量授权;后台线程查询)。内容模块横排最多 2 个:日历 / 系统资源(`SystemResourceMonitor`:CPU host_processor_info、内存 host_statistics64、网络 NET_RT_IFLIST2、磁盘 IOBlockStorageDriver、GPU IOAccelerator,全免 root;维护近 30 点采样历史供磁盘/网络 sparkline,CPU/内存/GPU 为用量进度条(灰轨道=100%);弹出后 0.4s 快速补采使速率项首值即显)/ 正在播放(`NowPlayingMonitor`:MRNowPlayingRequest 类属性轮询,类不可用降级 osascript;控制走 MRMediaRemoteSendCommand)。悬停 ≥ 延时(设置项)或单击热区弹出,点击面板外/Esc/移出热区∪面板/切 Space/睡眠/屏幕变化收起;无刘海或未授权则静默不生效。 +- **灵动岛**:`NotchHotspotController`(listen-only CGEventTap + idle→pending→visible 状态机;由公开 API 推导刘海热区:`safeAreaInsets.top` + `auxiliaryTopLeftArea`/`auxiliaryTopRightArea` 间隙)→ `NotchPanel`(非激活 NSPanel,固定深色 HUD,弹出/收起 = 从刘海矩形以弹簧动画「生长」展开/缩回(NotchSpringAnimator 主线程定时器积分,frame 0.4s 响应/阻尼比 0.8,打断无缝续接),宽:双模块取设置最大宽(`notchMaxWidth` 420–1200)/单模块按内容收缩(下限 420×120);高:内容驱动、上限设置最大高(`notchMaxHeight` 240–1000),日历日程列表装不下在剩余空间滚动(文字不缩),日程加载/切换后自适应重测面板高度(refit),系统资源卡拉伸至驱动卡高度、行均匀分布,仅固定部分超限才整体缩小;各模块包圆角半透明卡片背景,双卡片各占一半宽;顶边贴刘海下缘,圆角 16pt≈屏幕圆角(无公开 API);背景=液态玻璃 NSGlassEffectView(macOS 26+,系统默认外观不染色)/旧系统磨砂;无内容模块时显示「添加显示内容」引导跳设置)→ `NotchCalendarViewModel` → `CalendarService`(EventKit,macOS 14 全量授权;后台线程查询)。内容模块横排最多 2 个:日历 / 系统资源(`SystemResourceMonitor`:CPU host_processor_info、内存 host_statistics64、网络 NET_RT_IFLIST2、磁盘 IOBlockStorageDriver、GPU IOAccelerator,全免 root;维护近 30 点采样历史供磁盘/网络 sparkline,CPU/内存/GPU 为用量进度条(灰轨道=100%);弹出后 0.4s 快速补采使速率项首值即显)/ 正在播放(`NowPlayingMonitor`:MRNowPlayingRequest 类属性轮询,类不可用降级 osascript;控制走 MRMediaRemoteSendCommand)。悬停 ≥ 延时(设置项)或单击热区弹出,点击面板外/Esc/移出热区∪面板/切 Space/睡眠/屏幕变化收起;无刘海或未授权则静默不生效。 - **FinderSync 桥**:主 app(非沙盒)与扩展(沙盒)经 App Group 的共享文件通信——设置 plist `finder-sync-settings.plist`、命令请求 `finder-command-*.plist` + 分布式通知,自定义命令由主 app `FinderCommandRunner` 代执行;`FinderSyncReloader` 自愈陈旧扩展实例。**不要用 `UserDefaults(suiteName:)` 读 App Group**——原因及全部沙盒/登记坑见 `PITFALLS_DEV.md`。 - **基础**:`AccessibilityPermission` / `ScreenRecordingPermission`(权限检测)、`ZSLog`(日志,stderr + `/tmp/zeroflow.log`)、`L10n`(国际化:UI 文案一律 `L10n.tr("中文")` 作 key,新增文案需同步 `Resources/Translations.json`)。 diff --git a/PITFALLS_DEV.md b/PITFALLS_DEV.md index ec381e3..4590a16 100644 --- a/PITFALLS_DEV.md +++ b/PITFALLS_DEV.md @@ -101,3 +101,8 @@ - **Pitfall:** `kIOBlockStorageDriverStatisticsKey` 等按符号用报 "cannot find in scope";内存采样 count 直接用常量或拍脑袋会返回 KERN_INVALID_ARGUMENT。 - **Cause:** 这些键是 IOKit 头文件里的字符串宏(`#define kIOBlockStorageDriverStatisticsKey "Statistics"`,读/写键为 `"Bytes (Read)"`/`"Bytes (Write)"`),Swift 侧不导入;`HOST_VM_INFO64_COUNT` 也是宏。 - **Prevention:** IOKit 键直接写字符串值(真实值查 SDK 头文件 `IOKit.framework/Headers/storage/IOBlockStorageDriver.h`);`mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size)` 自算 count。资源监控数据源速记(macOS 27 实测,全免 root):CPU=host_processor_info 两次 ticks 差值;内存已用=active+inactive+speculative+wired+compressor−purgeable−external(≈活动监视器「内存已用」,≠top 的 PhysMem used=总−free,后者含缓存文件,别拿来对数);网络=sysctl NET_RT_IFLIST2 的 if_data64 逐接口差值(排除 IFT_LOOP);磁盘=IOService("IOBlockStorageDriver") 注册表 "Statistics" 的读写字节差值;GPU=IOAccelerator "PerformanceStatistics" 的 "Device Utilization %"(本机存在,瞬时值无需差分;备用 "GPU Activity(%)")。`swiftc | head` 会因管道 SIGPIPE 杀掉编译器且 `&&` 拿到的是 head 的退出码,验证脚本要重定向到文件再 grep。 + +## DEV-021: macOS 窗口 frame 弹簧动画无公开 API,需主线程定时器积分实现 +- **Pitfall:** 灵动岛面板要做 iPhone 式「从刘海长出/缩回」的弹簧动画,`NSAnimationContext` 只支持贝塞尔 `CAMediaTimingFunction`,`CASpringTimingFunction` 在 macOS 不公开,窗口 frame 无法用系统弹簧曲线动画。 +- **Cause:** NSWindow 经 animator() 可动画的属性走 Core Animation 但只暴露贝塞尔曲线;UIKit 语义的弹簧参数(response/dampingFraction)动画必须自己积分。 +- **Prevention:** 主线程 120Hz `Timer` 对弹簧微分方程半隐式欧拉积分(k=(2π/response)²,c=2ζ√k)后直接 `setFrame(_:display:)`(dt 远小于稳定域 2/ω 不会发散);打断续接需用各分量当前值与速度重建弹簧、作废旧动画的 completion(否则收起的 orderOut 会在重展开后误触发);Timer 必须加进 `.common` mode,否则面板按钮的事件跟踪期间动画冻结。参考 `NotchPanelView.swift` 的 `NotchSpringAnimator`。 diff --git a/Zeroflow/Services/NotchHotspotController.swift b/Zeroflow/Services/NotchHotspotController.swift index cd96ac8..cdaf28d 100644 --- a/Zeroflow/Services/NotchHotspotController.swift +++ b/Zeroflow/Services/NotchHotspotController.swift @@ -3,7 +3,7 @@ import ApplicationServices import Combine import CoreGraphics -/// 灵动岛:悬停/单击刘海区域,从刘海向下弹出磨砂面板(v1 内容 = 日历月视图)。 +/// 灵动岛:悬停/单击刘海区域,磨砂面板以 iOS 风格弹簧从刘海「生长」展开/缩回(v1 内容 = 日历月视图)。 /// /// 实现要点(结构仿 `DockHoverPreviewController`): /// - listen-only CGEventTap 监听 mouseMoved / mouseDown / keyDown,绝不吞事件: @@ -64,8 +64,8 @@ final class NotchHotspotController { private var panelShown = false private var notchScreen: NSScreen? private var dayEventsCancellable: AnyCancellable? - /// 最近一次 present 的热区/屏幕(日程变化后 refit 复用) - private var lastPresented: (screen: NSScreen, hotspotTL: CGRect)? + /// 最近一次 present 的热区/刘海矩形/屏幕(日程变化后 refit 复用;收起动画缩回刘海用 notchTL) + private var lastPresented: (screen: NSScreen, hotspotTL: CGRect, notchTL: CGRect)? // MARK: - 基础设施 @@ -446,18 +446,24 @@ final class NotchHotspotController { ZSLog("Notch: hide (\(reason))") DispatchQueue.main.async { [weak self] in guard let self else { return } - self.panel?.orderOut(nil) + let notchTL = self.lastPresented?.notchTL self.panelShown = false self.dayEventsCancellable = nil self.lastPresented = nil SystemResourceMonitor.shared.setActive(false) NowPlayingMonitor.shared.setActive(false) + // 视觉收起交给面板:弹簧缩回刘海再 orderOut;期间再 present 会无缝续接展开 + if let notchTL { + self.panel?.dismiss(notchTL: notchTL) + } else { + self.panel?.orderOut(nil) + } } } // MARK: - 展示(主线程) - /// 悬停延时到期 / 单击热区:主线程重建面板内容并滑入显示 + /// 悬停延时到期 / 单击热区:主线程重建面板内容并以弹簧动画从刘海展开显示 private func presentPanel(showGen gen: Int) { lock.lock() guard gen == showGen, phase == .pending else { @@ -480,9 +486,10 @@ final class NotchHotspotController { let viewModel = ensureModel() viewModel.reset() let panel = ensurePanel() - panel.present(hotspotTL: detected.hotspotTL, screen: detected.screen, model: viewModel) + panel.present(hotspotTL: detected.hotspotTL, notchTL: detected.notchTL, + screen: detected.screen, model: viewModel) panelShown = true - lastPresented = (detected.screen, detected.hotspotTL) + lastPresented = (detected.screen, detected.hotspotTL, detected.notchTL) startMonitors() startDayEventsRefit(model: viewModel) lock.lock() @@ -543,7 +550,7 @@ final class NotchHotspotController { /// 探测刘海屏并计算热区。无刘海返回 nil;ZEROFLOW_NOTCH_FAKE=1 时在主屏合成伪刘海。 @discardableResult - private func refreshHotspot() -> (screen: NSScreen, hotspotTL: CGRect)? { + private func refreshHotspot() -> (screen: NSScreen, hotspotTL: CGRect, notchTL: CGRect)? { let detected = detectNotch() notchScreen = detected?.screen lock.lock() @@ -552,7 +559,7 @@ final class NotchHotspotController { return detected } - private func detectNotch() -> (screen: NSScreen, hotspotTL: CGRect)? { + private func detectNotch() -> (screen: NSScreen, hotspotTL: CGRect, notchTL: CGRect)? { let unionMaxY = NSScreen.screens.reduce(CGRect.null) { $0.union($1.frame) }.maxY for screen in NSScreen.screens { let barHeight: CGFloat @@ -578,7 +585,7 @@ final class NotchHotspotController { if Self.debug { ZSLog("Notch: detect screen=\(screen.localizedName) notchTL=\(notchTL) hotspot=\(hotspot)") } - return (screen, hotspot) + return (screen, hotspotTL: hotspot, notchTL: notchTL) } return nil } diff --git a/Zeroflow/Views/NotchPanelView.swift b/Zeroflow/Views/NotchPanelView.swift index 5175268..84a1d33 100644 --- a/Zeroflow/Views/NotchPanelView.swift +++ b/Zeroflow/Views/NotchPanelView.swift @@ -884,7 +884,8 @@ private struct ResourceSparkline: View { } } -/// 灵动岛面板:非激活 borderless 面板,深色 HUD 磨砂,从刘海正下方滑入。 +/// 灵动岛面板:非激活 borderless 面板,深色 HUD 磨砂,以 iOS 风格弹簧动画从刘海 +/// 「生长」展开、收起时缩回刘海(NotchSpringAnimator 驱动 frame + alpha)。 /// 悬停/点击/Esc 判定全部由 NotchHotspotController 的 listen-only tap 完成, /// 本面板只负责内容展示(不成为 key,无键盘交互)。 final class NotchPanel: NSPanel { @@ -893,6 +894,12 @@ final class NotchPanel: NSPanel { static let minHeight: CGFloat = 120 /// 圆角半径(≈Mac 屏幕圆角;屏幕圆角无公开 API 可查询) static let cornerRadius: CGFloat = 16 + /// 展开 alpha 淡入响应(快速显形,避免深色 HUD 在黑色刘海上突兀浮现) + static let alphaInResponse: TimeInterval = 0.15 + /// 收起 alpha 淡出响应(比展开慢,缩回刘海的过程中基本保持可见) + static let alphaOutResponse: TimeInterval = 0.3 + + private lazy var springAnimator = NotchSpringAnimator(panel: self) init() { super.init(contentRect: NSRect(x: 0, y: 0, width: Self.minWidth, height: 200), @@ -1000,11 +1007,11 @@ final class NotchPanel: NSPanel { naturals: [], scale: 1) } - /// 依据刘海热区(主屏左上原点坐标)重建内容、测尺寸、定位并滑入显示。 + /// 依据刘海热区(主屏左上原点坐标)重建内容、测尺寸、定位,并从刘海矩形「生长」展开。 /// 面板顶边贴刘海下边缘(热区竖向范围 = 刘海竖向范围)、水平居中刘海; /// 每次 present 重建 contentView(模型重置到今天,同时取最新内容设置)。 /// 尺寸决策见 computeLayout(日程异步加载完成后的高度修正走 refit)。 - func present(hotspotTL: CGRect, screen: NSScreen, model: NotchCalendarViewModel) { + func present(hotspotTL: CGRect, notchTL: CGRect, screen: NSScreen, model: NotchCalendarViewModel) { let layout = Self.computeLayout(screen: screen, model: model) contentView = makeContentView(model: model, width: layout.width, height: layout.height, cardNaturals: layout.naturals, scale: layout.scale) @@ -1014,25 +1021,37 @@ final class NotchPanel: NSPanel { screen.frame.minX + 8, screen.frame.maxX - layout.width - 8) // 热区 TL 坐标最大 y = 刘海下边缘;转回 Cocoa 坐标作为面板顶边 let unionMaxY = NSScreen.screens.reduce(CGRect.null) { $0.union($1.frame) }.maxY - let finalOrigin = NSPoint(x: x, y: unionMaxY - hotspotTL.maxY - layout.height) - // 从刘海后方 12pt 处滑入;NSWindow 只有 frame 可动画(animator().setFrameOrigin 无效, - // 面板会停在起始位置),必须用 animator().setFrame(_:display:) - let startFrame = NSRect(origin: NSPoint(x: finalOrigin.x, y: finalOrigin.y + 12), - size: NSSize(width: layout.width, height: layout.height)) - let finalFrame = NSRect(origin: finalOrigin, size: NSSize(width: layout.width, height: layout.height)) - setFrame(startFrame, display: false) - alphaValue = 0 - orderFront(nil) - invalidateShadow() - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.18 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - animator().setFrame(finalFrame, display: true) - animator().alphaValue = 1 + let finalFrame = NSRect(x: x, y: unionMaxY - hotspotTL.maxY - layout.height, + width: layout.width, height: layout.height) + if isVisible { + // 收起动画被打断(快速反复进出):不重放,从当前 frame/alpha 无缝续接展开 + invalidateShadow() + } else { + // 起始 frame = 刘海矩形(盖住刘海,深色 HUD 与黑色刘海衔接)→ 弹簧展开 ≈ 刘海长大 + setFrame(cocoaNotchFrame(notchTL: notchTL, unionMaxY: unionMaxY), display: false) + alphaValue = 0 + orderFront(nil) + invalidateShadow() } + springAnimator.animate(to: finalFrame, alpha: 1, alphaResponse: Self.alphaInResponse) } - /// 日程加载/切换后重算布局并调整面板:顶边贴刘海不动(向下生长/收缩),带短动画。 + /// 收起:弹簧缩回刘海矩形并淡出,动画结束才 orderOut。控制器状态在 hidePanel 即时更新, + /// 期间再 present 会从当前 frame 无缝续接展开(本方法的 orderOut 已作废,不会执行)。 + func dismiss(notchTL: CGRect) { + let unionMaxY = NSScreen.screens.reduce(CGRect.null) { $0.union($1.frame) }.maxY + springAnimator.animate(to: cocoaNotchFrame(notchTL: notchTL, unionMaxY: unionMaxY), + alpha: 0, alphaResponse: Self.alphaOutResponse) { [weak self] in + self?.orderOut(nil) + } + } + + /// 刘海矩形(主屏左上原点坐标)→ Cocoa 坐标(面板盖在刘海正上方) + private func cocoaNotchFrame(notchTL: CGRect, unionMaxY: CGFloat) -> NSRect { + NSRect(x: notchTL.minX, y: unionMaxY - notchTL.maxY, width: notchTL.width, height: notchTL.height) + } + + /// 日程加载/切换后重算布局并调整面板:顶边贴刘海不动(向下生长/收缩),同款弹簧动画。 /// contentView 按新尺寸重建;尺寸不变时跳过。 func refit(hotspotTL: CGRect, screen: NSScreen, model: NotchCalendarViewModel) { let layout = Self.computeLayout(screen: screen, model: model) @@ -1044,11 +1063,8 @@ final class NotchPanel: NSPanel { let unionMaxY = NSScreen.screens.reduce(CGRect.null) { $0.union($1.frame) }.maxY let target = NSRect(origin: NSPoint(x: x, y: unionMaxY - hotspotTL.maxY - layout.height), size: NSSize(width: layout.width, height: layout.height)) - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.15 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - animator().setFrame(target, display: true) - } + // 同款弹簧(alpha 保持当前值不变),与展开/收起手感一致 + springAnimator.animate(to: target) invalidateShadow() } @@ -1097,3 +1113,149 @@ final class NotchPanel: NSPanel { return min(max(value, minValue), maxValue) } } + +/// iOS 风格弹簧动画驱动器(驱动 NotchPanel 的窗口 frame 与 alpha)。 +/// macOS 对窗口 frame 没有公开的弹簧动画 API(NSAnimationContext 仅支持贝塞尔曲线, +/// animator() 可动画属性的限制见 PITFALLS_DEV.md DEV-016),这里在主线程用定时器对弹簧 +/// 微分方程做半隐式欧拉积分后直接 setFrame。response + dampingFraction 语义同 UIKit, +/// dampingFraction < 1 带过冲回弹。 +/// 动画进行中再次 animate:各分量以当前值/速度为起点重定向(展开↔收起互相打断无缝续接), +/// 旧动画未执行的 completion 作废。 +private final class NotchSpringAnimator { + /// frame 弹簧:0.4s 响应、阻尼比 0.8(轻微回弹,接近 iPhone 灵动岛的手感) + static let frameResponse: TimeInterval = 0.4 + static let frameDampingFraction: CGFloat = 0.8 + + private struct Spring { + var value: CGFloat + var velocity: CGFloat + let target: CGFloat + let stiffness: CGFloat + let damping: CGFloat + let valueEpsilon: CGFloat + let velocityEpsilon: CGFloat + + init(value: CGFloat, velocity: CGFloat, target: CGFloat, response: TimeInterval, + dampingFraction: CGFloat, valueEpsilon: CGFloat, velocityEpsilon: CGFloat) { + self.value = value + self.velocity = velocity + self.target = target + // k = (2π/response)²,c = 2ζ√k(response/dampingFraction 同 UIKit 语义) + let k = pow(2 * CGFloat.pi / max(0.01, CGFloat(response)), 2) + self.stiffness = k + self.damping = 2 * dampingFraction * sqrt(k) + self.valueEpsilon = valueEpsilon + self.velocityEpsilon = velocityEpsilon + } + + /// 半隐式欧拉一步(dt 远小于稳定域 2/ω,帧率抖动不发散) + func stepped(dt: CGFloat) -> Spring { + var s = self + let force = -stiffness * (s.value - target) - damping * s.velocity + s.velocity += force * dt + s.value += s.velocity * dt + return s + } + + var settled: Bool { + abs(value - target) < valueEpsilon && abs(velocity) < velocityEpsilon + } + } + + /// 分量顺序:x, y, width, height, alpha + private var springs: [Spring]? + private var completion: (() -> Void)? + private var timer: Timer? + private weak var panel: NotchPanel? + + init(panel: NotchPanel) { + self.panel = panel + } + + deinit { + timer?.invalidate() + } + + /// 启动/重定向动画。alpha 传 nil = 保持进行中的 alpha 目标(无动画则保持当前 alpha)。 + /// 必须主线程调用(setFrame)。 + func animate(to targetFrame: NSRect, alpha targetAlpha: CGFloat? = nil, + alphaResponse: TimeInterval = 0.15, completion: (() -> Void)? = nil) { + guard let panel else { return } + self.completion = nil // 被打断动画的收尾作废 + + let frame = panel.frame + let previous = springs?.count == 5 ? springs : nil + let alphaTarget = targetAlpha ?? previous?[4].target ?? panel.alphaValue + + func make(_ index: Int, _ initial: CGFloat, _ target: CGFloat, _ response: TimeInterval, + _ dampingFraction: CGFloat, _ valueEpsilon: CGFloat, _ velocityEpsilon: CGFloat) -> Spring { + if let previous { + // 打断续接:沿用该分量当前的值与速度 + return Spring(value: previous[index].value, velocity: previous[index].velocity, + target: target, response: response, dampingFraction: dampingFraction, + valueEpsilon: valueEpsilon, velocityEpsilon: velocityEpsilon) + } + return Spring(value: initial, velocity: 0, target: target, response: response, + dampingFraction: dampingFraction, valueEpsilon: valueEpsilon, + velocityEpsilon: velocityEpsilon) + } + + springs = [ + make(0, frame.minX, targetFrame.minX, Self.frameResponse, Self.frameDampingFraction, 0.5, 5), + make(1, frame.minY, targetFrame.minY, Self.frameResponse, Self.frameDampingFraction, 0.5, 5), + make(2, frame.width, targetFrame.width, Self.frameResponse, Self.frameDampingFraction, 0.5, 5), + make(3, frame.height, targetFrame.height, Self.frameResponse, Self.frameDampingFraction, 0.5, 5), + make(4, panel.alphaValue, alphaTarget, alphaResponse, 1, 0.005, 0.05), + ] + self.completion = completion + startTimerIfNeeded() + } + + private func startTimerIfNeeded() { + guard timer == nil else { return } + var t: Timer? + t = Timer(timeInterval: 1.0 / 120.0, repeats: true) { [weak self] _ in + guard let self else { t?.invalidate(); return } + self.tick() + } + // .common:面板内按钮按下等事件跟踪期间动画不冻结 + RunLoop.main.add(t!, forMode: .common) + timer = t + } + + private func tick() { + guard let panel, var springs else { + stopTimer() + return + } + let dt: CGFloat = 1 / 120 + var allSettled = true + for i in springs.indices { + if !springs[i].settled { + springs[i] = springs[i].stepped(dt: dt) + } + if springs[i].settled { + springs[i].value = springs[i].target + springs[i].velocity = 0 + } else { + allSettled = false + } + } + self.springs = springs + panel.setFrame(NSRect(x: springs[0].value, y: springs[1].value, + width: springs[2].value, height: springs[3].value), display: true) + panel.alphaValue = springs[4].value + if allSettled { + stopTimer() + let completion = self.completion + self.completion = nil + completion?() + } + } + + private func stopTimer() { + timer?.invalidate() + timer = nil + springs = nil + } +} From cb15835cb348478e82aeaa893530b8f0c7933ded Mon Sep 17 00:00:00 2001 From: hellolio Date: Sat, 24 Oct 2026 21:44:39 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=E7=81=B5=E5=8A=A8=E5=B2=9B=E5=8A=A8?= =?UTF-8?q?=E7=94=BB=E4=BF=AE=E6=AD=A3:alpha=20=E8=B7=9F=E9=9A=8F=E5=B1=95?= =?UTF-8?q?=E5=BC=80/=E6=94=B6=E7=BC=A9=E5=87=A0=E4=BD=95=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6,=E5=BC=B9=E7=B0=A7=E6=94=BE=E6=85=A2=E5=8A=A0?= =?UTF-8?q?=E5=A4=A7=E5=9B=9E=E5=BC=B9,=E6=B6=88=E9=99=A4=E3=80=8C?= =?UTF-8?q?=E4=BB=8E=E4=B8=80=E4=BE=A7=E6=BB=91=E5=85=A5=E3=80=8D=E6=84=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- PITFALLS_DEV.md | 5 ++ Zeroflow/Views/NotchPanelView.swift | 97 +++++++++++++++++++---------- 3 files changed, 71 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6123960..9b11809 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ SwiftUI 视图 + AppKit/CG/AX(含私有 API dlsym 桥)服务层 + UserDefaults/A - **截图链**:`GlobalHotkeyManager`(Carbon 热键,受 `screenshotEnabled` 总开关控制)→ `CaptureCoordinator`(权限→遮罩→捕获→编辑页,`isActive` 防重入)→ `ScreenCapture`(ScreenCaptureKit,按 displayID 缓存 `SCContentFilter`)→ `CaptureOverlayView`(选区遮罩)→ `EditorView`(编辑页)。 - **窗口管理族**(切换器与 Dock 预览共用):`WindowList`(CGWindowList 枚举 + 幽灵过滤 + 窗口级 MRU + 无窗口 app 占位)→ `PhantomWindowDetector`(幽灵判定,对齐 AltTab)/ `WindowActivityTracker`(AX 焦点维护 MRU)/ `WindowThumbnailer`(SkyLight 抓缩略图,能截最小化/离屏)/ `WindowActivator`(跨 Space 前台激活)/ `WindowOps`(卡片操作);消费方 `CommandTabSwitcher`(⌘⇥ 面板)与 `DockHoverPreviewController` + `DockPreviewPanel`。`CGSWindowServer` 是上述私有 API 的 dlsym 桥,启动需预热。 - **Dock**:`DockClickMinimizer`(单击最小化),开关变化经通知启停 CGEventTap。 -- **灵动岛**:`NotchHotspotController`(listen-only CGEventTap + idle→pending→visible 状态机;由公开 API 推导刘海热区:`safeAreaInsets.top` + `auxiliaryTopLeftArea`/`auxiliaryTopRightArea` 间隙)→ `NotchPanel`(非激活 NSPanel,固定深色 HUD,弹出/收起 = 从刘海矩形以弹簧动画「生长」展开/缩回(NotchSpringAnimator 主线程定时器积分,frame 0.4s 响应/阻尼比 0.8,打断无缝续接),宽:双模块取设置最大宽(`notchMaxWidth` 420–1200)/单模块按内容收缩(下限 420×120);高:内容驱动、上限设置最大高(`notchMaxHeight` 240–1000),日历日程列表装不下在剩余空间滚动(文字不缩),日程加载/切换后自适应重测面板高度(refit),系统资源卡拉伸至驱动卡高度、行均匀分布,仅固定部分超限才整体缩小;各模块包圆角半透明卡片背景,双卡片各占一半宽;顶边贴刘海下缘,圆角 16pt≈屏幕圆角(无公开 API);背景=液态玻璃 NSGlassEffectView(macOS 26+,系统默认外观不染色)/旧系统磨砂;无内容模块时显示「添加显示内容」引导跳设置)→ `NotchCalendarViewModel` → `CalendarService`(EventKit,macOS 14 全量授权;后台线程查询)。内容模块横排最多 2 个:日历 / 系统资源(`SystemResourceMonitor`:CPU host_processor_info、内存 host_statistics64、网络 NET_RT_IFLIST2、磁盘 IOBlockStorageDriver、GPU IOAccelerator,全免 root;维护近 30 点采样历史供磁盘/网络 sparkline,CPU/内存/GPU 为用量进度条(灰轨道=100%);弹出后 0.4s 快速补采使速率项首值即显)/ 正在播放(`NowPlayingMonitor`:MRNowPlayingRequest 类属性轮询,类不可用降级 osascript;控制走 MRMediaRemoteSendCommand)。悬停 ≥ 延时(设置项)或单击热区弹出,点击面板外/Esc/移出热区∪面板/切 Space/睡眠/屏幕变化收起;无刘海或未授权则静默不生效。 +- **灵动岛**:`NotchHotspotController`(listen-only CGEventTap + idle→pending→visible 状态机;由公开 API 推导刘海热区:`safeAreaInsets.top` + `auxiliaryTopLeftArea`/`auxiliaryTopRightArea` 间隙)→ `NotchPanel`(非激活 NSPanel,固定深色 HUD,弹出/收起 = 从刘海矩形以弹簧动画「生长」展开/缩回(NotchSpringAnimator 主线程定时器积分,frame 0.5s 响应/阻尼比 0.72,alpha 跟随几何进度渐显渐隐而非独立淡入淡出,打断无缝续接),宽:双模块取设置最大宽(`notchMaxWidth` 420–1200)/单模块按内容收缩(下限 420×120);高:内容驱动、上限设置最大高(`notchMaxHeight` 240–1000),日历日程列表装不下在剩余空间滚动(文字不缩),日程加载/切换后自适应重测面板高度(refit),系统资源卡拉伸至驱动卡高度、行均匀分布,仅固定部分超限才整体缩小;各模块包圆角半透明卡片背景,双卡片各占一半宽;顶边贴刘海下缘,圆角 16pt≈屏幕圆角(无公开 API);背景=液态玻璃 NSGlassEffectView(macOS 26+,系统默认外观不染色)/旧系统磨砂;无内容模块时显示「添加显示内容」引导跳设置)→ `NotchCalendarViewModel` → `CalendarService`(EventKit,macOS 14 全量授权;后台线程查询)。内容模块横排最多 2 个:日历 / 系统资源(`SystemResourceMonitor`:CPU host_processor_info、内存 host_statistics64、网络 NET_RT_IFLIST2、磁盘 IOBlockStorageDriver、GPU IOAccelerator,全免 root;维护近 30 点采样历史供磁盘/网络 sparkline,CPU/内存/GPU 为用量进度条(灰轨道=100%);弹出后 0.4s 快速补采使速率项首值即显)/ 正在播放(`NowPlayingMonitor`:MRNowPlayingRequest 类属性轮询,类不可用降级 osascript;控制走 MRMediaRemoteSendCommand)。悬停 ≥ 延时(设置项)或单击热区弹出,点击面板外/Esc/移出热区∪面板/切 Space/睡眠/屏幕变化收起;无刘海或未授权则静默不生效。 - **FinderSync 桥**:主 app(非沙盒)与扩展(沙盒)经 App Group 的共享文件通信——设置 plist `finder-sync-settings.plist`、命令请求 `finder-command-*.plist` + 分布式通知,自定义命令由主 app `FinderCommandRunner` 代执行;`FinderSyncReloader` 自愈陈旧扩展实例。**不要用 `UserDefaults(suiteName:)` 读 App Group**——原因及全部沙盒/登记坑见 `PITFALLS_DEV.md`。 - **基础**:`AccessibilityPermission` / `ScreenRecordingPermission`(权限检测)、`ZSLog`(日志,stderr + `/tmp/zeroflow.log`)、`L10n`(国际化:UI 文案一律 `L10n.tr("中文")` 作 key,新增文案需同步 `Resources/Translations.json`)。 diff --git a/PITFALLS_DEV.md b/PITFALLS_DEV.md index 4590a16..9cb2652 100644 --- a/PITFALLS_DEV.md +++ b/PITFALLS_DEV.md @@ -106,3 +106,8 @@ - **Pitfall:** 灵动岛面板要做 iPhone 式「从刘海长出/缩回」的弹簧动画,`NSAnimationContext` 只支持贝塞尔 `CAMediaTimingFunction`,`CASpringTimingFunction` 在 macOS 不公开,窗口 frame 无法用系统弹簧曲线动画。 - **Cause:** NSWindow 经 animator() 可动画的属性走 Core Animation 但只暴露贝塞尔曲线;UIKit 语义的弹簧参数(response/dampingFraction)动画必须自己积分。 - **Prevention:** 主线程 120Hz `Timer` 对弹簧微分方程半隐式欧拉积分(k=(2π/response)²,c=2ζ√k)后直接 `setFrame(_:display:)`(dt 远小于稳定域 2/ω 不会发散);打断续接需用各分量当前值与速度重建弹簧、作废旧动画的 completion(否则收起的 orderOut 会在重展开后误触发);Timer 必须加进 `.common` mode,否则面板按钮的事件跟踪期间动画冻结。参考 `NotchPanelView.swift` 的 `NotchSpringAnimator`。 + +## DEV-022: 窗口弹簧动画里 alpha 走独立快速淡入,会把「生长」动画感知成「从一侧滑入」 +- **Pitfall:** 灵动岛面板做「从刘海长出」弹簧动画,frame 用弹簧、alpha 用独立的 0.15s 快速淡入:用户看到的是半透明面板从一侧快速滑到位 + 渐显,完全不是「从刘海逐渐长大」,方向感全错。 +- **Cause:** 弹簧位移前 ~15% 时间就走完 85% 路程,独立淡入结束时可变大半已展开;肉眼能捕捉的只剩后段残留位移( leading 边缘扫过),感知成滑入。弹性动画里透明度与几何分开调,时序感知必然错位。 +- **Prevention:** alpha 跟随几何进度推导(alpha = captured + (target−captured)×clamp(尺寸进度/fadeSpan)),打断时 captured=当前 alpha、起点=当前尺寸即无跳变;展开 fadeSpan≈0.5(前半程半透明、全程可见生长),收起 fadeSpan=1(全程可见收缩、缩到刘海黑底上恰好消失)。参考 `NotchSpringAnimator.progressAlpha`。 diff --git a/Zeroflow/Views/NotchPanelView.swift b/Zeroflow/Views/NotchPanelView.swift index 84a1d33..3c7e790 100644 --- a/Zeroflow/Views/NotchPanelView.swift +++ b/Zeroflow/Views/NotchPanelView.swift @@ -894,10 +894,10 @@ final class NotchPanel: NSPanel { static let minHeight: CGFloat = 120 /// 圆角半径(≈Mac 屏幕圆角;屏幕圆角无公开 API 可查询) static let cornerRadius: CGFloat = 16 - /// 展开 alpha 淡入响应(快速显形,避免深色 HUD 在黑色刘海上突兀浮现) - static let alphaInResponse: TimeInterval = 0.15 - /// 收起 alpha 淡出响应(比展开慢,缩回刘海的过程中基本保持可见) - static let alphaOutResponse: TimeInterval = 0.3 + /// alpha 跟随展开进度:进行到 50% 时完全显形(前期半透明,「从刘海长出」全程可见) + static let alphaInSpan: CGFloat = 0.5 + /// alpha 跟随收缩进度:全程线性淡出,缩回刘海时恰好消失(黑底上无感收尾) + static let alphaOutSpan: CGFloat = 1.0 private lazy var springAnimator = NotchSpringAnimator(panel: self) @@ -1033,7 +1033,7 @@ final class NotchPanel: NSPanel { orderFront(nil) invalidateShadow() } - springAnimator.animate(to: finalFrame, alpha: 1, alphaResponse: Self.alphaInResponse) + springAnimator.animate(to: finalFrame, alpha: 1, fadeSpan: Self.alphaInSpan) } /// 收起:弹簧缩回刘海矩形并淡出,动画结束才 orderOut。控制器状态在 hidePanel 即时更新, @@ -1041,7 +1041,7 @@ final class NotchPanel: NSPanel { func dismiss(notchTL: CGRect) { let unionMaxY = NSScreen.screens.reduce(CGRect.null) { $0.union($1.frame) }.maxY springAnimator.animate(to: cocoaNotchFrame(notchTL: notchTL, unionMaxY: unionMaxY), - alpha: 0, alphaResponse: Self.alphaOutResponse) { [weak self] in + alpha: 0, fadeSpan: Self.alphaOutSpan) { [weak self] in self?.orderOut(nil) } } @@ -1114,17 +1114,18 @@ final class NotchPanel: NSPanel { } } -/// iOS 风格弹簧动画驱动器(驱动 NotchPanel 的窗口 frame 与 alpha)。 +/// iOS 风格弹簧动画驱动器(驱动 NotchPanel 的窗口 frame;alpha 跟随几何进度渐显/渐隐)。 /// macOS 对窗口 frame 没有公开的弹簧动画 API(NSAnimationContext 仅支持贝塞尔曲线, /// animator() 可动画属性的限制见 PITFALLS_DEV.md DEV-016),这里在主线程用定时器对弹簧 /// 微分方程做半隐式欧拉积分后直接 setFrame。response + dampingFraction 语义同 UIKit, /// dampingFraction < 1 带过冲回弹。 -/// 动画进行中再次 animate:各分量以当前值/速度为起点重定向(展开↔收起互相打断无缝续接), -/// 旧动画未执行的 completion 作废。 +/// alpha 不走独立曲线:独立快速淡入会让早期生长不可见(感知成「从一侧滑入」,DEV-022), +/// 改为按尺寸进度渐显/渐隐。动画进行中再次 animate:各分量以当前值/速度为起点重定向 +/// (展开↔收起互相打断无缝续接),旧动画未执行的 completion 作废。 private final class NotchSpringAnimator { - /// frame 弹簧:0.4s 响应、阻尼比 0.8(轻微回弹,接近 iPhone 灵动岛的手感) - static let frameResponse: TimeInterval = 0.4 - static let frameDampingFraction: CGFloat = 0.8 + /// frame 弹簧:0.5s 响应、阻尼比 0.72(展开过程全程可见 + 明显回弹) + static let frameResponse: TimeInterval = 0.5 + static let frameDampingFraction: CGFloat = 0.72 private struct Spring { var value: CGFloat @@ -1162,11 +1163,19 @@ private final class NotchSpringAnimator { } } - /// 分量顺序:x, y, width, height, alpha + /// 分量顺序:x, y, width, height(alpha 由几何进度推导,不单独积分) private var springs: [Spring]? private var completion: (() -> Void)? private var timer: Timer? private weak var panel: NotchPanel? + /// alpha = captured + (target − captured) × clamp(尺寸进度 / fadeSpan, 0, 1) + private var startWidth: CGFloat = 0 + private var startHeight: CGFloat = 0 + private var targetWidth: CGFloat = 0 + private var targetHeight: CGFloat = 0 + private var capturedAlpha: CGFloat = 1 + private var targetAlpha: CGFloat = 1 + private var fadeSpan: CGFloat = 1 init(panel: NotchPanel) { self.panel = panel @@ -1176,36 +1185,42 @@ private final class NotchSpringAnimator { timer?.invalidate() } - /// 启动/重定向动画。alpha 传 nil = 保持进行中的 alpha 目标(无动画则保持当前 alpha)。 - /// 必须主线程调用(setFrame)。 + /// 启动/重定向动画。alpha 传 nil = 保持在途的 alpha 目标(无在途动画则保持当前 alpha)。 + /// fadeSpan:alpha 到位所需的几何进度比例(1 = 与展开同步)。必须主线程调用(setFrame)。 func animate(to targetFrame: NSRect, alpha targetAlpha: CGFloat? = nil, - alphaResponse: TimeInterval = 0.15, completion: (() -> Void)? = nil) { + fadeSpan span: CGFloat = 1, completion: (() -> Void)? = nil) { guard let panel else { return } self.completion = nil // 被打断动画的收尾作废 + let inFlight = springs != nil let frame = panel.frame - let previous = springs?.count == 5 ? springs : nil - let alphaTarget = targetAlpha ?? previous?[4].target ?? panel.alphaValue - - func make(_ index: Int, _ initial: CGFloat, _ target: CGFloat, _ response: TimeInterval, - _ dampingFraction: CGFloat, _ valueEpsilon: CGFloat, _ velocityEpsilon: CGFloat) -> Spring { + let previous = springs?.count == 4 ? springs : nil + + startWidth = frame.width + startHeight = frame.height + targetWidth = targetFrame.width + targetHeight = targetFrame.height + capturedAlpha = panel.alphaValue + // refit(alpha nil)时保留在途 alpha 目标,避免展开中途淡入被冻结在半透明 + self.targetAlpha = targetAlpha ?? (inFlight ? self.targetAlpha : panel.alphaValue) + fadeSpan = span + + func make(_ index: Int, _ initial: CGFloat, _ target: CGFloat) -> Spring { if let previous { // 打断续接:沿用该分量当前的值与速度 return Spring(value: previous[index].value, velocity: previous[index].velocity, - target: target, response: response, dampingFraction: dampingFraction, - valueEpsilon: valueEpsilon, velocityEpsilon: velocityEpsilon) + target: target, response: Self.frameResponse, + dampingFraction: Self.frameDampingFraction, valueEpsilon: 0.5, velocityEpsilon: 5) } - return Spring(value: initial, velocity: 0, target: target, response: response, - dampingFraction: dampingFraction, valueEpsilon: valueEpsilon, - velocityEpsilon: velocityEpsilon) + return Spring(value: initial, velocity: 0, target: target, response: Self.frameResponse, + dampingFraction: Self.frameDampingFraction, valueEpsilon: 0.5, velocityEpsilon: 5) } springs = [ - make(0, frame.minX, targetFrame.minX, Self.frameResponse, Self.frameDampingFraction, 0.5, 5), - make(1, frame.minY, targetFrame.minY, Self.frameResponse, Self.frameDampingFraction, 0.5, 5), - make(2, frame.width, targetFrame.width, Self.frameResponse, Self.frameDampingFraction, 0.5, 5), - make(3, frame.height, targetFrame.height, Self.frameResponse, Self.frameDampingFraction, 0.5, 5), - make(4, panel.alphaValue, alphaTarget, alphaResponse, 1, 0.005, 0.05), + make(0, frame.minX, targetFrame.minX), + make(1, frame.minY, targetFrame.minY), + make(2, frame.width, targetFrame.width), + make(3, frame.height, targetFrame.height), ] self.completion = completion startTimerIfNeeded() @@ -1244,7 +1259,7 @@ private final class NotchSpringAnimator { self.springs = springs panel.setFrame(NSRect(x: springs[0].value, y: springs[1].value, width: springs[2].value, height: springs[3].value), display: true) - panel.alphaValue = springs[4].value + panel.alphaValue = progressAlpha(width: springs[2].value, height: springs[3].value) if allSettled { stopTimer() let completion = self.completion @@ -1253,6 +1268,24 @@ private final class NotchSpringAnimator { } } + /// alpha 随几何进度渐显/渐隐:进度 = 当前尺寸离开本次动画起点的比例(宽优先,宽没动用高; + /// 同参数弹簧各分量归一化进度相同,取宽即整 frame 进度), + /// alpha = captured + (target − captured) × clamp(进度 / fadeSpan, 0, 1)。 + /// 打断时 captured = 当前 alpha、起点 = 当前尺寸,故续接无跳变。 + private func progressAlpha(width: CGFloat, height: CGFloat) -> CGFloat { + let dw = targetWidth - startWidth + let dh = targetHeight - startHeight + var progress: CGFloat = 1 + if abs(dw) > 0.5 { + progress = (width - startWidth) / dw + } else if abs(dh) > 0.5 { + progress = (height - startHeight) / dh + } + progress = min(1, max(0, progress)) + let t = min(1, progress / max(0.05, fadeSpan)) + return capturedAlpha + (targetAlpha - capturedAlpha) * t + } + private func stopTimer() { timer?.invalidate() timer = nil From b31364cb708b21f136dc712efa2dc2a7a34725ee Mon Sep 17 00:00:00 2001 From: hellolio Date: Sat, 24 Oct 2026 22:02:36 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=E7=81=B5=E5=8A=A8=E5=B2=9B:=E5=BC=B9?= =?UTF-8?q?=E5=87=BA/=E6=94=B6=E8=B5=B7=E6=94=B9=E4=B8=BA=E5=85=A8?= =?UTF-8?q?=E7=A8=8B=E4=B8=8D=E9=80=8F=E6=98=8E=E7=9A=84=E5=BD=A2=E7=8A=B6?= =?UTF-8?q?=E4=BA=92=E5=8F=98(=E5=88=98=E6=B5=B7=E8=8D=AF=E4=B8=B8?= =?UTF-8?q?=E2=86=94=E9=95=BF=E6=96=B9=E5=BD=A2),=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=E9=80=8F=E6=98=8E=E5=BA=A6=E6=B8=90=E5=8F=98,=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=89=93=E6=96=AD=E8=B7=AF=E5=BE=84=20setContentSize?= =?UTF-8?q?=20=E9=80=A0=E6=88=90=E7=9A=84=E6=95=B4=E5=9D=97=E5=B9=B3?= =?UTF-8?q?=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- PITFALLS_DEV.md | 7 ++- Zeroflow/Views/NotchPanelView.swift | 83 ++++++++--------------------- 3 files changed, 29 insertions(+), 63 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9b11809..87ca554 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ SwiftUI 视图 + AppKit/CG/AX(含私有 API dlsym 桥)服务层 + UserDefaults/A - **截图链**:`GlobalHotkeyManager`(Carbon 热键,受 `screenshotEnabled` 总开关控制)→ `CaptureCoordinator`(权限→遮罩→捕获→编辑页,`isActive` 防重入)→ `ScreenCapture`(ScreenCaptureKit,按 displayID 缓存 `SCContentFilter`)→ `CaptureOverlayView`(选区遮罩)→ `EditorView`(编辑页)。 - **窗口管理族**(切换器与 Dock 预览共用):`WindowList`(CGWindowList 枚举 + 幽灵过滤 + 窗口级 MRU + 无窗口 app 占位)→ `PhantomWindowDetector`(幽灵判定,对齐 AltTab)/ `WindowActivityTracker`(AX 焦点维护 MRU)/ `WindowThumbnailer`(SkyLight 抓缩略图,能截最小化/离屏)/ `WindowActivator`(跨 Space 前台激活)/ `WindowOps`(卡片操作);消费方 `CommandTabSwitcher`(⌘⇥ 面板)与 `DockHoverPreviewController` + `DockPreviewPanel`。`CGSWindowServer` 是上述私有 API 的 dlsym 桥,启动需预热。 - **Dock**:`DockClickMinimizer`(单击最小化),开关变化经通知启停 CGEventTap。 -- **灵动岛**:`NotchHotspotController`(listen-only CGEventTap + idle→pending→visible 状态机;由公开 API 推导刘海热区:`safeAreaInsets.top` + `auxiliaryTopLeftArea`/`auxiliaryTopRightArea` 间隙)→ `NotchPanel`(非激活 NSPanel,固定深色 HUD,弹出/收起 = 从刘海矩形以弹簧动画「生长」展开/缩回(NotchSpringAnimator 主线程定时器积分,frame 0.5s 响应/阻尼比 0.72,alpha 跟随几何进度渐显渐隐而非独立淡入淡出,打断无缝续接),宽:双模块取设置最大宽(`notchMaxWidth` 420–1200)/单模块按内容收缩(下限 420×120);高:内容驱动、上限设置最大高(`notchMaxHeight` 240–1000),日历日程列表装不下在剩余空间滚动(文字不缩),日程加载/切换后自适应重测面板高度(refit),系统资源卡拉伸至驱动卡高度、行均匀分布,仅固定部分超限才整体缩小;各模块包圆角半透明卡片背景,双卡片各占一半宽;顶边贴刘海下缘,圆角 16pt≈屏幕圆角(无公开 API);背景=液态玻璃 NSGlassEffectView(macOS 26+,系统默认外观不染色)/旧系统磨砂;无内容模块时显示「添加显示内容」引导跳设置)→ `NotchCalendarViewModel` → `CalendarService`(EventKit,macOS 14 全量授权;后台线程查询)。内容模块横排最多 2 个:日历 / 系统资源(`SystemResourceMonitor`:CPU host_processor_info、内存 host_statistics64、网络 NET_RT_IFLIST2、磁盘 IOBlockStorageDriver、GPU IOAccelerator,全免 root;维护近 30 点采样历史供磁盘/网络 sparkline,CPU/内存/GPU 为用量进度条(灰轨道=100%);弹出后 0.4s 快速补采使速率项首值即显)/ 正在播放(`NowPlayingMonitor`:MRNowPlayingRequest 类属性轮询,类不可用降级 osascript;控制走 MRMediaRemoteSendCommand)。悬停 ≥ 延时(设置项)或单击热区弹出,点击面板外/Esc/移出热区∪面板/切 Space/睡眠/屏幕变化收起;无刘海或未授权则静默不生效。 +- **灵动岛**:`NotchHotspotController`(listen-only CGEventTap + idle→pending→visible 状态机;由公开 API 推导刘海热区:`safeAreaInsets.top` + `auxiliaryTopLeftArea`/`auxiliaryTopRightArea` 间隙)→ `NotchPanel`(非激活 NSPanel,固定深色 HUD,弹出/收起 = 从刘海矩形以弹簧动画「生长」展开/缩回(NotchSpringAnimator 主线程定时器积分,frame 0.5s 响应/阻尼比 0.72,全程不透明无透明度渐变,形状在刘海形药丸↔长方形间弹簧互变,打断无缝续接),宽:双模块取设置最大宽(`notchMaxWidth` 420–1200)/单模块按内容收缩(下限 420×120);高:内容驱动、上限设置最大高(`notchMaxHeight` 240–1000),日历日程列表装不下在剩余空间滚动(文字不缩),日程加载/切换后自适应重测面板高度(refit),系统资源卡拉伸至驱动卡高度、行均匀分布,仅固定部分超限才整体缩小;各模块包圆角半透明卡片背景,双卡片各占一半宽;顶边贴刘海下缘,圆角 16pt≈屏幕圆角(无公开 API);背景=液态玻璃 NSGlassEffectView(macOS 26+,系统默认外观不染色)/旧系统磨砂;无内容模块时显示「添加显示内容」引导跳设置)→ `NotchCalendarViewModel` → `CalendarService`(EventKit,macOS 14 全量授权;后台线程查询)。内容模块横排最多 2 个:日历 / 系统资源(`SystemResourceMonitor`:CPU host_processor_info、内存 host_statistics64、网络 NET_RT_IFLIST2、磁盘 IOBlockStorageDriver、GPU IOAccelerator,全免 root;维护近 30 点采样历史供磁盘/网络 sparkline,CPU/内存/GPU 为用量进度条(灰轨道=100%);弹出后 0.4s 快速补采使速率项首值即显)/ 正在播放(`NowPlayingMonitor`:MRNowPlayingRequest 类属性轮询,类不可用降级 osascript;控制走 MRMediaRemoteSendCommand)。悬停 ≥ 延时(设置项)或单击热区弹出,点击面板外/Esc/移出热区∪面板/切 Space/睡眠/屏幕变化收起;无刘海或未授权则静默不生效。 - **FinderSync 桥**:主 app(非沙盒)与扩展(沙盒)经 App Group 的共享文件通信——设置 plist `finder-sync-settings.plist`、命令请求 `finder-command-*.plist` + 分布式通知,自定义命令由主 app `FinderCommandRunner` 代执行;`FinderSyncReloader` 自愈陈旧扩展实例。**不要用 `UserDefaults(suiteName:)` 读 App Group**——原因及全部沙盒/登记坑见 `PITFALLS_DEV.md`。 - **基础**:`AccessibilityPermission` / `ScreenRecordingPermission`(权限检测)、`ZSLog`(日志,stderr + `/tmp/zeroflow.log`)、`L10n`(国际化:UI 文案一律 `L10n.tr("中文")` 作 key,新增文案需同步 `Resources/Translations.json`)。 diff --git a/PITFALLS_DEV.md b/PITFALLS_DEV.md index 9cb2652..ec0d9af 100644 --- a/PITFALLS_DEV.md +++ b/PITFALLS_DEV.md @@ -110,4 +110,9 @@ ## DEV-022: 窗口弹簧动画里 alpha 走独立快速淡入,会把「生长」动画感知成「从一侧滑入」 - **Pitfall:** 灵动岛面板做「从刘海长出」弹簧动画,frame 用弹簧、alpha 用独立的 0.15s 快速淡入:用户看到的是半透明面板从一侧快速滑到位 + 渐显,完全不是「从刘海逐渐长大」,方向感全错。 - **Cause:** 弹簧位移前 ~15% 时间就走完 85% 路程,独立淡入结束时可变大半已展开;肉眼能捕捉的只剩后段残留位移( leading 边缘扫过),感知成滑入。弹性动画里透明度与几何分开调,时序感知必然错位。 -- **Prevention:** alpha 跟随几何进度推导(alpha = captured + (target−captured)×clamp(尺寸进度/fadeSpan)),打断时 captured=当前 alpha、起点=当前尺寸即无跳变;展开 fadeSpan≈0.5(前半程半透明、全程可见生长),收起 fadeSpan=1(全程可见收缩、缩到刘海黑底上恰好消失)。参考 `NotchSpringAnimator.progressAlpha`。 +- **Prevention:** 「刘海生长/缩回」动画全程不透明(深色 HUD 起点盖住黑色刘海,出现/消失都无感,形状渐变本身就是全部视觉),透明度完全不参与动画;若别的窗口必须有淡入淡出,alpha 须由几何进度推导而非独立时长,打断时以当前值续接。 + +## DEV-023: 打断在途窗口动画时,严禁 setContentSize/setFrame 直接改窗口尺寸 +- **Pitfall:** 面板「收起弹簧未播完→再次弹出」走打断路径时调用了 `setContentSize`(最终尺寸):窗口几何绕过弹簧直接跳变(尺寸瞬间到位、按原点锚定另一侧瞬移),随后位置再弹簧滑到位 → 用户看到「全尺寸面板从屏幕一侧平移到中间」,与弹簧动画预期完全不符。 +- **Cause:** `setContentSize`/`setFrame` 是瞬时几何赋值;打断场景窗口处于中间态,直接改尺寸等于把「在途位移」换成「瞬移 + 平移收尾」。`setContentSize` 还按窗口原点(左下)锚定缩放,另一侧边缘瞬移,平移感更明显。 +- **Prevention:** 打断动画只做一件事——弹簧目标重定向(以当前值/速度续接);任何路径都不要在动画在途时用 `setContentSize`/`setFrame` 改尺寸。`present` 的起始定位(setFrame 刘海矩形)只放在「窗口不可见」的全新展开分支。参考 `NotchPanel.present`。 diff --git a/Zeroflow/Views/NotchPanelView.swift b/Zeroflow/Views/NotchPanelView.swift index 3c7e790..8cdbf31 100644 --- a/Zeroflow/Views/NotchPanelView.swift +++ b/Zeroflow/Views/NotchPanelView.swift @@ -885,7 +885,8 @@ private struct ResourceSparkline: View { } /// 灵动岛面板:非激活 borderless 面板,深色 HUD 磨砂,以 iOS 风格弹簧动画从刘海 -/// 「生长」展开、收起时缩回刘海(NotchSpringAnimator 驱动 frame + alpha)。 +/// 「生长」展开、收起时缩回刘海(NotchSpringAnimator 只驱动 frame,全程不透明: +/// 形状在刘海形药丸↔长方形间弹簧互变,无透明度渐变)。 /// 悬停/点击/Esc 判定全部由 NotchHotspotController 的 listen-only tap 完成, /// 本面板只负责内容展示(不成为 key,无键盘交互)。 final class NotchPanel: NSPanel { @@ -894,11 +895,6 @@ final class NotchPanel: NSPanel { static let minHeight: CGFloat = 120 /// 圆角半径(≈Mac 屏幕圆角;屏幕圆角无公开 API 可查询) static let cornerRadius: CGFloat = 16 - /// alpha 跟随展开进度:进行到 50% 时完全显形(前期半透明,「从刘海长出」全程可见) - static let alphaInSpan: CGFloat = 0.5 - /// alpha 跟随收缩进度:全程线性淡出,缩回刘海时恰好消失(黑底上无感收尾) - static let alphaOutSpan: CGFloat = 1.0 - private lazy var springAnimator = NotchSpringAnimator(panel: self) init() { @@ -1015,7 +1011,8 @@ final class NotchPanel: NSPanel { let layout = Self.computeLayout(screen: screen, model: model) contentView = makeContentView(model: model, width: layout.width, height: layout.height, cardNaturals: layout.naturals, scale: layout.scale) - setContentSize(NSSize(width: layout.width, height: layout.height)) + // 注意:这里不 setContentSize —— 它是瞬时几何赋值,打断路径上会把窗口尺寸直接 + // 跳到终值(原点锚定、另一侧瞬移),产生整块平移感(DEV-023);尺寸交给弹簧 setFrame let x = Self.clamp(hotspotTL.midX - layout.width / 2, screen.frame.minX + 8, screen.frame.maxX - layout.width - 8) @@ -1024,24 +1021,26 @@ final class NotchPanel: NSPanel { let finalFrame = NSRect(x: x, y: unionMaxY - hotspotTL.maxY - layout.height, width: layout.width, height: layout.height) if isVisible { - // 收起动画被打断(快速反复进出):不重放,从当前 frame/alpha 无缝续接展开 + // 收起动画被打断(快速反复进出):不重放、不触碰窗口几何,只重定向弹簧, + // 从当前 frame/速度无缝续接展开 invalidateShadow() } else { - // 起始 frame = 刘海矩形(盖住刘海,深色 HUD 与黑色刘海衔接)→ 弹簧展开 ≈ 刘海长大 + // 起始 frame = 刘海矩形:不透明深色 HUD 正好盖住黑色刘海(出现无感), + // 从刘海形状向下、向两侧弹簧生长成长方形,全程无透明度变化 setFrame(cocoaNotchFrame(notchTL: notchTL, unionMaxY: unionMaxY), display: false) - alphaValue = 0 + alphaValue = 1 orderFront(nil) invalidateShadow() } - springAnimator.animate(to: finalFrame, alpha: 1, fadeSpan: Self.alphaInSpan) + springAnimator.animate(to: finalFrame) } - /// 收起:弹簧缩回刘海矩形并淡出,动画结束才 orderOut。控制器状态在 hidePanel 即时更新, + /// 收起:全程不透明,弹簧缩回刘海矩形(形状从长方形变回刘海形状),动画结束才 orderOut + /// (终点覆盖黑色刘海,消失无感)。控制器状态在 hidePanel 即时更新, /// 期间再 present 会从当前 frame 无缝续接展开(本方法的 orderOut 已作废,不会执行)。 func dismiss(notchTL: CGRect) { let unionMaxY = NSScreen.screens.reduce(CGRect.null) { $0.union($1.frame) }.maxY - springAnimator.animate(to: cocoaNotchFrame(notchTL: notchTL, unionMaxY: unionMaxY), - alpha: 0, fadeSpan: Self.alphaOutSpan) { [weak self] in + springAnimator.animate(to: cocoaNotchFrame(notchTL: notchTL, unionMaxY: unionMaxY)) { [weak self] in self?.orderOut(nil) } } @@ -1063,7 +1062,7 @@ final class NotchPanel: NSPanel { let unionMaxY = NSScreen.screens.reduce(CGRect.null) { $0.union($1.frame) }.maxY let target = NSRect(origin: NSPoint(x: x, y: unionMaxY - hotspotTL.maxY - layout.height), size: NSSize(width: layout.width, height: layout.height)) - // 同款弹簧(alpha 保持当前值不变),与展开/收起手感一致 + // 同款弹簧,与展开/收起手感一致 springAnimator.animate(to: target) invalidateShadow() } @@ -1114,14 +1113,15 @@ final class NotchPanel: NSPanel { } } -/// iOS 风格弹簧动画驱动器(驱动 NotchPanel 的窗口 frame;alpha 跟随几何进度渐显/渐隐)。 +/// iOS 风格弹簧动画驱动器(只驱动 NotchPanel 的窗口 frame,透明度恒定、不参与动画)。 /// macOS 对窗口 frame 没有公开的弹簧动画 API(NSAnimationContext 仅支持贝塞尔曲线, /// animator() 可动画属性的限制见 PITFALLS_DEV.md DEV-016),这里在主线程用定时器对弹簧 /// 微分方程做半隐式欧拉积分后直接 setFrame。response + dampingFraction 语义同 UIKit, /// dampingFraction < 1 带过冲回弹。 -/// alpha 不走独立曲线:独立快速淡入会让早期生长不可见(感知成「从一侧滑入」,DEV-022), -/// 改为按尺寸进度渐显/渐隐。动画进行中再次 animate:各分量以当前值/速度为起点重定向 -/// (展开↔收起互相打断无缝续接),旧动画未执行的 completion 作废。 +/// 「刘海生长/缩回」全程必须不透明:任何 alpha 淡入淡出都会让早期生长不可见、 +/// 被感知成「从一侧滑入」(DEV-022),形状渐变本身就是全部视觉效果。 +/// 动画进行中再次 animate:各分量以当前值/速度为起点重定向(展开↔收起互相打断无缝续接), +/// 旧动画未执行的 completion 作废。 private final class NotchSpringAnimator { /// frame 弹簧:0.5s 响应、阻尼比 0.72(展开过程全程可见 + 明显回弹) static let frameResponse: TimeInterval = 0.5 @@ -1163,19 +1163,11 @@ private final class NotchSpringAnimator { } } - /// 分量顺序:x, y, width, height(alpha 由几何进度推导,不单独积分) + /// 分量顺序:x, y, width, height private var springs: [Spring]? private var completion: (() -> Void)? private var timer: Timer? private weak var panel: NotchPanel? - /// alpha = captured + (target − captured) × clamp(尺寸进度 / fadeSpan, 0, 1) - private var startWidth: CGFloat = 0 - private var startHeight: CGFloat = 0 - private var targetWidth: CGFloat = 0 - private var targetHeight: CGFloat = 0 - private var capturedAlpha: CGFloat = 1 - private var targetAlpha: CGFloat = 1 - private var fadeSpan: CGFloat = 1 init(panel: NotchPanel) { self.panel = panel @@ -1185,26 +1177,14 @@ private final class NotchSpringAnimator { timer?.invalidate() } - /// 启动/重定向动画。alpha 传 nil = 保持在途的 alpha 目标(无在途动画则保持当前 alpha)。 - /// fadeSpan:alpha 到位所需的几何进度比例(1 = 与展开同步)。必须主线程调用(setFrame)。 - func animate(to targetFrame: NSRect, alpha targetAlpha: CGFloat? = nil, - fadeSpan span: CGFloat = 1, completion: (() -> Void)? = nil) { + /// 启动/重定向动画(只驱动窗口 frame)。必须主线程调用(setFrame)。 + func animate(to targetFrame: NSRect, completion: (() -> Void)? = nil) { guard let panel else { return } self.completion = nil // 被打断动画的收尾作废 - let inFlight = springs != nil let frame = panel.frame let previous = springs?.count == 4 ? springs : nil - startWidth = frame.width - startHeight = frame.height - targetWidth = targetFrame.width - targetHeight = targetFrame.height - capturedAlpha = panel.alphaValue - // refit(alpha nil)时保留在途 alpha 目标,避免展开中途淡入被冻结在半透明 - self.targetAlpha = targetAlpha ?? (inFlight ? self.targetAlpha : panel.alphaValue) - fadeSpan = span - func make(_ index: Int, _ initial: CGFloat, _ target: CGFloat) -> Spring { if let previous { // 打断续接:沿用该分量当前的值与速度 @@ -1259,7 +1239,6 @@ private final class NotchSpringAnimator { self.springs = springs panel.setFrame(NSRect(x: springs[0].value, y: springs[1].value, width: springs[2].value, height: springs[3].value), display: true) - panel.alphaValue = progressAlpha(width: springs[2].value, height: springs[3].value) if allSettled { stopTimer() let completion = self.completion @@ -1268,24 +1247,6 @@ private final class NotchSpringAnimator { } } - /// alpha 随几何进度渐显/渐隐:进度 = 当前尺寸离开本次动画起点的比例(宽优先,宽没动用高; - /// 同参数弹簧各分量归一化进度相同,取宽即整 frame 进度), - /// alpha = captured + (target − captured) × clamp(进度 / fadeSpan, 0, 1)。 - /// 打断时 captured = 当前 alpha、起点 = 当前尺寸,故续接无跳变。 - private func progressAlpha(width: CGFloat, height: CGFloat) -> CGFloat { - let dw = targetWidth - startWidth - let dh = targetHeight - startHeight - var progress: CGFloat = 1 - if abs(dw) > 0.5 { - progress = (width - startWidth) / dw - } else if abs(dh) > 0.5 { - progress = (height - startHeight) / dh - } - progress = min(1, max(0, progress)) - let t = min(1, progress / max(0.05, fadeSpan)) - return capturedAlpha + (targetAlpha - capturedAlpha) * t - } - private func stopTimer() { timer?.invalidate() timer = nil From 5da949528983c952915c384b60754018ba7acfab Mon Sep 17 00:00:00 2001 From: hellolio Date: Sat, 24 Oct 2026 23:08:05 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=E7=81=B5=E5=8A=A8=E5=B2=9B=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/NotchHotspotController.swift | 5 +- Zeroflow/Views/NotchPanelView.swift | 97 +++++++++++++------ 2 files changed, 70 insertions(+), 32 deletions(-) diff --git a/Zeroflow/Services/NotchHotspotController.swift b/Zeroflow/Services/NotchHotspotController.swift index cdaf28d..36e2ecd 100644 --- a/Zeroflow/Services/NotchHotspotController.swift +++ b/Zeroflow/Services/NotchHotspotController.swift @@ -493,7 +493,8 @@ final class NotchHotspotController { startMonitors() startDayEventsRefit(model: viewModel) lock.lock() - panelFrameTL = toTopLeftCoords(panel.frame) + // 用弹簧目标 frame,而非 present() 返回时刻的瞬时 frame(动画刚从刘海开始) + panelFrameTL = toTopLeftCoords(panel.targetFrame) lock.unlock() if Self.debug { ZSLog("Notch: show gen=\(gen) hotspot=\(detected.hotspotTL) panel=\(panel.frame)") @@ -542,7 +543,7 @@ final class NotchHotspotController { guard visible, let panel, let model, let presented = lastPresented else { return } panel.refit(hotspotTL: presented.hotspotTL, screen: presented.screen, model: model) lock.lock() - panelFrameTL = toTopLeftCoords(panel.frame) + panelFrameTL = toTopLeftCoords(panel.targetFrame) lock.unlock() } diff --git a/Zeroflow/Views/NotchPanelView.swift b/Zeroflow/Views/NotchPanelView.swift index 8cdbf31..8260d4f 100644 --- a/Zeroflow/Views/NotchPanelView.swift +++ b/Zeroflow/Views/NotchPanelView.swift @@ -896,6 +896,11 @@ final class NotchPanel: NSPanel { /// 圆角半径(≈Mac 屏幕圆角;屏幕圆角无公开 API 可查询) static let cornerRadius: CGFloat = 16 private lazy var springAnimator = NotchSpringAnimator(panel: self) + /// 弹簧正在趋向的目标 frame(present/refit/dismiss 更新)。 + /// 控制器保活/点击判定用这个,而不是 present() 返回时刻的瞬时 frame: + /// 展开动画从刘海矩形开始,那一刻的真实 frame 还是刘海大小(DEV-024 修复后 + /// 窗口尺寸真实可变,原来「present 后 frame 已是终值」的隐含前提不再成立)。 + private(set) var targetFrame: NSRect = .zero init() { super.init(contentRect: NSRect(x: 0, y: 0, width: Self.minWidth, height: 200), @@ -1020,13 +1025,14 @@ final class NotchPanel: NSPanel { let unionMaxY = NSScreen.screens.reduce(CGRect.null) { $0.union($1.frame) }.maxY let finalFrame = NSRect(x: x, y: unionMaxY - hotspotTL.maxY - layout.height, width: layout.width, height: layout.height) + targetFrame = finalFrame if isVisible { // 收起动画被打断(快速反复进出):不重放、不触碰窗口几何,只重定向弹簧, // 从当前 frame/速度无缝续接展开 invalidateShadow() } else { - // 起始 frame = 刘海矩形:不透明深色 HUD 正好盖住黑色刘海(出现无感), - // 从刘海形状向下、向两侧弹簧生长成长方形,全程无透明度变化 + // 起始 frame = 刘海中心的半尺寸迷你药丸(嵌在刘海内),弹簧生长成长方形; + // 全程不透明(DEV-022),形状渐变本身就是全部视觉效果 setFrame(cocoaNotchFrame(notchTL: notchTL, unionMaxY: unionMaxY), display: false) alphaValue = 1 orderFront(nil) @@ -1035,19 +1041,27 @@ final class NotchPanel: NSPanel { springAnimator.animate(to: finalFrame) } - /// 收起:全程不透明,弹簧缩回刘海矩形(形状从长方形变回刘海形状),动画结束才 orderOut - /// (终点覆盖黑色刘海,消失无感)。控制器状态在 hidePanel 即时更新, - /// 期间再 present 会从当前 frame 无缝续接展开(本方法的 orderOut 已作废,不会执行)。 + /// 收起:全程不透明,弹簧缩回刘海中心的半尺寸迷你药丸,动画结束才 orderOut。 + /// 控制器状态在 hidePanel 即时更新,期间再 present 会从当前 frame 无缝续接展开 + /// (本方法的 orderOut 已作废,不会执行)。 func dismiss(notchTL: CGRect) { let unionMaxY = NSScreen.screens.reduce(CGRect.null) { $0.union($1.frame) }.maxY - springAnimator.animate(to: cocoaNotchFrame(notchTL: notchTL, unionMaxY: unionMaxY)) { [weak self] in + let notchFrame = cocoaNotchFrame(notchTL: notchTL, unionMaxY: unionMaxY) + targetFrame = notchFrame + springAnimator.animate(to: notchFrame, collapse: true) { [weak self] in self?.orderOut(nil) } } /// 刘海矩形(主屏左上原点坐标)→ Cocoa 坐标(面板盖在刘海正上方) + /// 刘海矩形(主屏左上原点坐标)→ Cocoa 坐标。展开起点/收起终点:以刘海中心为锚 + /// 缩到一半尺寸的迷你药丸(嵌在刘海内的初始形态),从它弹簧生长/缩回后消失。 private func cocoaNotchFrame(notchTL: CGRect, unionMaxY: CGFloat) -> NSRect { - NSRect(x: notchTL.minX, y: unionMaxY - notchTL.maxY, width: notchTL.width, height: notchTL.height) + let half = NSSize(width: notchTL.width / 2, height: notchTL.height / 2) + return NSRect(x: notchTL.midX - half.width / 2, + y: unionMaxY - notchTL.midY - half.height / 2, + width: half.width, + height: half.height) } /// 日程加载/切换后重算布局并调整面板:顶边贴刘海不动(向下生长/收缩),同款弹簧动画。 @@ -1062,13 +1076,19 @@ final class NotchPanel: NSPanel { let unionMaxY = NSScreen.screens.reduce(CGRect.null) { $0.union($1.frame) }.maxY let target = NSRect(origin: NSPoint(x: x, y: unionMaxY - hotspotTL.maxY - layout.height), size: NSSize(width: layout.width, height: layout.height)) + targetFrame = target // 同款弹簧,与展开/收起手感一致 springAnimator.animate(to: target) invalidateShadow() } - /// 液态玻璃背景(macOS 26+)或磨砂底(旧系统)包裹内容,圆角≈屏幕圆角。 + /// 液态玻璃背景(macOS 26+)或磨砂底(旧系统),内容以 autoresizing 跟随铺满,圆角≈屏幕圆角。 /// 不透明度不再可调:使用系统默认外观(玻璃不染色、磨砂不降 alpha)。 + /// + /// 结构红线(DEV-024):hosting 必须以 addSubview + autoresizingMask 跟随容器, + /// 绝不能赋给 glass.contentView、绝不能对 hosting 用 Auto Layout 约束—— + /// SwiftUI 固定 .frame 尺寸会经约束/玻璃层反向钳制窗口尺寸,setFrame 的宽高 + /// 被静默锁死(只有原点生效),「从刘海生长」就退化成全尺寸面板平移。 private func makeContentView(model: NotchCalendarViewModel, width: CGFloat, height: CGFloat, cardNaturals: [CGSize], scale: CGFloat) -> NSView { let hosting = NSHostingView(rootView: NotchContentView(model: model, @@ -1076,13 +1096,13 @@ final class NotchPanel: NSPanel { displayHeight: height, cardNaturals: cardNaturals, cardScale: scale)) - hosting.translatesAutoresizingMaskIntoConstraints = false if #available(macOS 26.0, *) { let glass = NSGlassEffectView() glass.cornerRadius = Self.cornerRadius glass.style = .regular - glass.contentView = hosting - pinEdges(of: hosting, to: glass) + glass.addSubview(hosting) + hosting.frame = glass.bounds + hosting.autoresizingMask = [.width, .height] return glass } let frost = NSVisualEffectView() @@ -1094,19 +1114,11 @@ final class NotchPanel: NSPanel { frost.layer?.cornerCurve = .continuous frost.layer?.masksToBounds = true frost.addSubview(hosting) - pinEdges(of: hosting, to: frost) + hosting.frame = frost.bounds + hosting.autoresizingMask = [.width, .height] return frost } - private func pinEdges(of view: NSView, to container: NSView) { - NSLayoutConstraint.activate([ - view.leadingAnchor.constraint(equalTo: container.leadingAnchor), - view.trailingAnchor.constraint(equalTo: container.trailingAnchor), - view.topAnchor.constraint(equalTo: container.topAnchor), - view.bottomAnchor.constraint(equalTo: container.bottomAnchor), - ]) - } - private static func clamp(_ value: CGFloat, _ minValue: CGFloat, _ maxValue: CGFloat) -> CGFloat { guard maxValue >= minValue else { return minValue } return min(max(value, minValue), maxValue) @@ -1117,15 +1129,36 @@ final class NotchPanel: NSPanel { /// macOS 对窗口 frame 没有公开的弹簧动画 API(NSAnimationContext 仅支持贝塞尔曲线, /// animator() 可动画属性的限制见 PITFALLS_DEV.md DEV-016),这里在主线程用定时器对弹簧 /// 微分方程做半隐式欧拉积分后直接 setFrame。response + dampingFraction 语义同 UIKit, -/// dampingFraction < 1 带过冲回弹。 +/// dampingFraction < 1 带过冲回弹。分量差异化调参见 expandTuning/collapseTuning。 /// 「刘海生长/缩回」全程必须不透明:任何 alpha 淡入淡出都会让早期生长不可见、 /// 被感知成「从一侧滑入」(DEV-022),形状渐变本身就是全部视觉效果。 /// 动画进行中再次 animate:各分量以当前值/速度为起点重定向(展开↔收起互相打断无缝续接), /// 旧动画未执行的 completion 作废。 private final class NotchSpringAnimator { - /// frame 弹簧:0.5s 响应、阻尼比 0.72(展开过程全程可见 + 明显回弹) - static let frameResponse: TimeInterval = 0.5 - static let frameDampingFraction: CGFloat = 0.72 + /// 分量弹簧调参(response + 阻尼比),顺序对齐 springs:x, y, width, height。 + /// 灵动岛式液体拉伸:宽先冲出(带过冲)、高慢半拍平滑下填、位置快速就位几乎不晃; + /// 单一组参数(四分量同步、阻尼比 0.72 过冲仅 ~4%)会被感知成线性生长。 + private struct SpringTuning { + let response: TimeInterval + let dampingFraction: CGFloat + } + /// 展开/重定向 + private static let expandTuning: [SpringTuning] = [ + .init(response: 0.32, dampingFraction: 0.90), // x + .init(response: 0.32, dampingFraction: 0.90), // y + .init(response: 0.38, dampingFraction: 0.72), // width:先冲出,~5% 过冲 + .init(response: 0.52, dampingFraction: 0.78), // height:慢半拍跟上 + ] + /// 收起:宽度更快折回,更利落 + private static let collapseTuning: [SpringTuning] = [ + .init(response: 0.32, dampingFraction: 0.90), // x + .init(response: 0.32, dampingFraction: 0.90), // y + .init(response: 0.32, dampingFraction: 0.72), // width:更快收拢 + .init(response: 0.52, dampingFraction: 0.78), // height + ] + /// settle 阈值:收紧到 0.25pt / 2pt·s⁻¹,避免尾部带速急停的微顿 + private static let valueEpsilon: CGFloat = 0.25 + private static let velocityEpsilon: CGFloat = 2 private struct Spring { var value: CGFloat @@ -1178,22 +1211,26 @@ private final class NotchSpringAnimator { } /// 启动/重定向动画(只驱动窗口 frame)。必须主线程调用(setFrame)。 - func animate(to targetFrame: NSRect, completion: (() -> Void)? = nil) { + /// collapse = 收起方向(宽度弹簧加速,折回更利落)。 + func animate(to targetFrame: NSRect, collapse: Bool = false, completion: (() -> Void)? = nil) { guard let panel else { return } self.completion = nil // 被打断动画的收尾作废 let frame = panel.frame let previous = springs?.count == 4 ? springs : nil + let tuning = collapse ? Self.collapseTuning : Self.expandTuning func make(_ index: Int, _ initial: CGFloat, _ target: CGFloat) -> Spring { if let previous { // 打断续接:沿用该分量当前的值与速度 return Spring(value: previous[index].value, velocity: previous[index].velocity, - target: target, response: Self.frameResponse, - dampingFraction: Self.frameDampingFraction, valueEpsilon: 0.5, velocityEpsilon: 5) + target: target, response: tuning[index].response, + dampingFraction: tuning[index].dampingFraction, + valueEpsilon: Self.valueEpsilon, velocityEpsilon: Self.velocityEpsilon) } - return Spring(value: initial, velocity: 0, target: target, response: Self.frameResponse, - dampingFraction: Self.frameDampingFraction, valueEpsilon: 0.5, velocityEpsilon: 5) + return Spring(value: initial, velocity: 0, target: target, response: tuning[index].response, + dampingFraction: tuning[index].dampingFraction, + valueEpsilon: Self.valueEpsilon, velocityEpsilon: Self.velocityEpsilon) } springs = [