diff --git a/AGENTS.md b/AGENTS.md index 2aabe7f..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,宽:双模块取设置最大宽(`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 ec381e3..ec0d9af 100644 --- a/PITFALLS_DEV.md +++ b/PITFALLS_DEV.md @@ -101,3 +101,18 @@ - **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`。 + +## DEV-022: 窗口弹簧动画里 alpha 走独立快速淡入,会把「生长」动画感知成「从一侧滑入」 +- **Pitfall:** 灵动岛面板做「从刘海长出」弹簧动画,frame 用弹簧、alpha 用独立的 0.15s 快速淡入:用户看到的是半透明面板从一侧快速滑到位 + 渐显,完全不是「从刘海逐渐长大」,方向感全错。 +- **Cause:** 弹簧位移前 ~15% 时间就走完 85% 路程,独立淡入结束时可变大半已展开;肉眼能捕捉的只剩后段残留位移( leading 边缘扫过),感知成滑入。弹性动画里透明度与几何分开调,时序感知必然错位。 +- **Prevention:** 「刘海生长/缩回」动画全程不透明(深色 HUD 起点盖住黑色刘海,出现/消失都无感,形状渐变本身就是全部视觉),透明度完全不参与动画;若别的窗口必须有淡入淡出,alpha 须由几何进度推导而非独立时长,打断时以当前值续接。 + +## DEV-023: 打断在途窗口动画时,严禁 setContentSize/setFrame 直接改窗口尺寸 +- **Pitfall:** 面板「收起弹簧未播完→再次弹出」走打断路径时调用了 `setContentSize`(最终尺寸):窗口几何绕过弹簧直接跳变(尺寸瞬间到位、按原点锚定另一侧瞬移),随后位置再弹簧滑到位 → 用户看到「全尺寸面板从屏幕一侧平移到中间」,与弹簧动画预期完全不符。 +- **Cause:** `setContentSize`/`setFrame` 是瞬时几何赋值;打断场景窗口处于中间态,直接改尺寸等于把「在途位移」换成「瞬移 + 平移收尾」。`setContentSize` 还按窗口原点(左下)锚定缩放,另一侧边缘瞬移,平移感更明显。 +- **Prevention:** 打断动画只做一件事——弹簧目标重定向(以当前值/速度续接);任何路径都不要在动画在途时用 `setContentSize`/`setFrame` 改尺寸。`present` 的起始定位(setFrame 刘海矩形)只放在「窗口不可见」的全新展开分支。参考 `NotchPanel.present`。 diff --git a/Zeroflow/Services/NotchHotspotController.swift b/Zeroflow/Services/NotchHotspotController.swift index cd96ac8..36e2ecd 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,13 +486,15 @@ 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() - 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)") @@ -535,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() } @@ -543,7 +551,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 +560,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 +586,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..8260d4f 100644 --- a/Zeroflow/Views/NotchPanelView.swift +++ b/Zeroflow/Views/NotchPanelView.swift @@ -884,7 +884,9 @@ private struct ResourceSparkline: View { } } -/// 灵动岛面板:非激活 borderless 面板,深色 HUD 磨砂,从刘海正下方滑入。 +/// 灵动岛面板:非激活 borderless 面板,深色 HUD 磨砂,以 iOS 风格弹簧动画从刘海 +/// 「生长」展开、收起时缩回刘海(NotchSpringAnimator 只驱动 frame,全程不透明: +/// 形状在刘海形药丸↔长方形间弹簧互变,无透明度渐变)。 /// 悬停/点击/Esc 判定全部由 NotchHotspotController 的 listen-only tap 完成, /// 本面板只负责内容展示(不成为 key,无键盘交互)。 final class NotchPanel: NSPanel { @@ -893,6 +895,12 @@ final class NotchPanel: NSPanel { static let minHeight: CGFloat = 120 /// 圆角半径(≈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), @@ -1000,39 +1008,63 @@ 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) - 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) // 热区 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) + targetFrame = finalFrame + if isVisible { + // 收起动画被打断(快速反复进出):不重放、不触碰窗口几何,只重定向弹簧, + // 从当前 frame/速度无缝续接展开 + invalidateShadow() + } else { + // 起始 frame = 刘海中心的半尺寸迷你药丸(嵌在刘海内),弹簧生长成长方形; + // 全程不透明(DEV-022),形状渐变本身就是全部视觉效果 + setFrame(cocoaNotchFrame(notchTL: notchTL, unionMaxY: unionMaxY), display: false) + alphaValue = 1 + orderFront(nil) + invalidateShadow() + } + springAnimator.animate(to: finalFrame) + } + + /// 收起:全程不透明,弹簧缩回刘海中心的半尺寸迷你药丸,动画结束才 orderOut。 + /// 控制器状态在 hidePanel 即时更新,期间再 present 会从当前 frame 无缝续接展开 + /// (本方法的 orderOut 已作废,不会执行)。 + func dismiss(notchTL: CGRect) { + let unionMaxY = NSScreen.screens.reduce(CGRect.null) { $0.union($1.frame) }.maxY + 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 { + 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) + } + + /// 日程加载/切换后重算布局并调整面板:顶边贴刘海不动(向下生长/收缩),同款弹簧动画。 /// contentView 按新尺寸重建;尺寸不变时跳过。 func refit(hotspotTL: CGRect, screen: NSScreen, model: NotchCalendarViewModel) { let layout = Self.computeLayout(screen: screen, model: model) @@ -1044,16 +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)) - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.15 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - animator().setFrame(target, display: true) - } + 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, @@ -1061,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() @@ -1079,21 +1114,179 @@ 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) } } + +/// iOS 风格弹簧动画驱动器(只驱动 NotchPanel 的窗口 frame,透明度恒定、不参与动画)。 +/// macOS 对窗口 frame 没有公开的弹簧动画 API(NSAnimationContext 仅支持贝塞尔曲线, +/// animator() 可动画属性的限制见 PITFALLS_DEV.md DEV-016),这里在主线程用定时器对弹簧 +/// 微分方程做半隐式欧拉积分后直接 setFrame。response + dampingFraction 语义同 UIKit, +/// dampingFraction < 1 带过冲回弹。分量差异化调参见 expandTuning/collapseTuning。 +/// 「刘海生长/缩回」全程必须不透明:任何 alpha 淡入淡出都会让早期生长不可见、 +/// 被感知成「从一侧滑入」(DEV-022),形状渐变本身就是全部视觉效果。 +/// 动画进行中再次 animate:各分量以当前值/速度为起点重定向(展开↔收起互相打断无缝续接), +/// 旧动画未执行的 completion 作废。 +private final class NotchSpringAnimator { + /// 分量弹簧调参(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 + 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 + 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() + } + + /// 启动/重定向动画(只驱动窗口 frame)。必须主线程调用(setFrame)。 + /// 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: tuning[index].response, + dampingFraction: tuning[index].dampingFraction, + valueEpsilon: Self.valueEpsilon, velocityEpsilon: Self.velocityEpsilon) + } + return Spring(value: initial, velocity: 0, target: target, response: tuning[index].response, + dampingFraction: tuning[index].dampingFraction, + valueEpsilon: Self.valueEpsilon, velocityEpsilon: Self.velocityEpsilon) + } + + springs = [ + 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() + } + + 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) + if allSettled { + stopTimer() + let completion = self.completion + self.completion = nil + completion?() + } + } + + private func stopTimer() { + timer?.invalidate() + timer = nil + springs = nil + } +}