# 多窗口显示关系 - 整理日期:2026-06-11 - 复核日期:2026-06-15 - 依据产品版本:TagLauncher 7.9.1,Build 20260614.1454 - 版本依据:`src/Apptag/Info.plist`、`src/CHANGELOG.md`、`src/Release/AppStore-7.9.1-20260614.1454/QA_RELEASE_EVIDENCE.md` - 配套图示:[02-多窗口显示关系.drawio](./02-多窗口显示关系.drawio) ## 窗口角色 TagLauncher 的窗口不是普通单窗口 SwiftUI App,而是由多个 AppKit 窗口组成: | 窗口 | 类型 | 创建位置 | 作用 | | ------------------ | ----------------------------- | ---------------------------------------------------------- | ----------------------------------------------------- | | App Grid overlay | `OverlayPanel: NSPanel` | `OverlayWindowController.makeOverlayWindow` | 全屏透明/毛玻璃承载层,内容是 `DismissibleHostingView(ContentView)` | | Quick Search panel | `QuickSearchPanel: NSPanel` | `QuickSearchPanelPresentationView.Coordinator.ensurePanel` | 独立搜索面板,作为 overlay child window 显示 | | Settings window | `NSWindow` | `AppDelegate.openPreferences` | 固定 1000x480 设置窗口,可作为 overlay child window | | 文件面板 | `NSSavePanel` / `NSOpenPanel` | `PreferencesView.exportTags/importTags` | 导入导出 tags JSON,优先作为 Settings sheet | | 菜单栏菜单 | `NSStatusItem` + `NSMenu` | `AppDelegate.setupMenuBar` | 显示版本、设置、语言、退出和打开 App Grid | Overlay、Quick Search、Settings 是最需要保护的窗口栈。当前 QA 预期中,TagLauncher 窗口 layer 通常为 23,系统 menubar layer 为 24;overlay 可隐藏 Dock 窗口,Quick Search/Settings 应位于 overlay 之上。 ## 产品验收规则对齐 以下规则是后续窗口改动的产品验收基线;技术实现可以调整,但不能降低这些可见行为。 | 产品规则 | 当前技术落点 | 验收含义 | | ---------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------- | | Settings 必须在 App Grid 上方 | `overlayWindow.addChildWindow(settingsWindow, ordered: .above)`,Settings level 跟随 overlay | 打开设置时 App Grid 不应遮挡 Settings,也不应触发 Space 跳转 | | 导入/导出文件面板必须在 Settings 上方 | `NSSavePanel` / `NSOpenPanel` 优先作为 Settings sheet | 文件选择、保存面板必须保持最高的 TagLauncher 交互层级 | | Force Quit 必须高于 TagLauncher | 系统 `⌥⌘Esc` 窗口不应被 overlay、Quick Search 或 Settings 遮挡 | 即使 TagLauncher 假死,用户仍能强制退出进程 | | Quick Search 必须在 App Grid 上方、Settings 下方 | Quick Search 是 overlay child window;打开 Settings 前会 `dismissQuickSearchIfNeeded` | App Grid 打开时按 Space 不应隐藏 App Grid;若 Settings 参与交互,Settings 优先 | | App Grid 内按 Space 只打开 Quick Search | `mainOverlay` source 保留 App Grid 背景 | 关闭 Quick Search 后应回到 App Grid,而不是直接退出整个 overlay | | Quick Search 失焦或 Esc 应关闭搜索 | mouse monitor、Esc 处理、`.tagLauncherQuickSearchDismissRequested` | 第一次 Esc 关闭 Quick Search;第二次 Esc 才关闭 App Grid | | 多屏幕必须跟随光标焦点 | `overlayPlacementContextForNextOverlay` 优先选择鼠标所在屏幕 | 无论目标 App 是否全屏,App Grid 应出现在当前光标所在屏幕 | | 全屏/Split View 不切 Space | `hasFullscreenWindowOnScreen` / `avoidsSpaceSwitch=true` | `⌥⇧Space` 打开的 App Grid 必须覆盖当前全屏 Space,而不是跳去其它 Space | | 显示 App Grid 时 Dock 隐藏且菜单栏归属正确 | overlay 显示期间刷新 Dock/activation policy,`claimLauncherForeground` 抢前台 | 用户看到的是 TagLauncher 菜单栏,Dock 不应露出干扰 | ## Overlay 生命周期 Overlay 由 `OverlayWindowController` 管理,`AppDelegate` 只通过依赖注入提供策略和回调。 显示流程: 1. `AppDelegate.performToggleOverlay` 或 `showOverlay` 调用 `overlayController.show/toggle`。 2. `OverlayWindowController.overlayPlacementContextForNextOverlay` 选择鼠标所在屏幕或菜单栏所在屏幕。 3. `hasFullscreenWindowOnScreen` 通过 `CGWindowListCopyWindowInfo` 判断目标屏幕是否处在全屏或 Split View。 4. 如果需要避免 Space 切换,先把 App staging 到 `.accessory`,再延迟显示 overlay。 5. 创建 borderless、nonactivating、full-size `OverlayPanel`,设置 `.canJoinAllSpaces`、`.fullScreenAuxiliary`、`.stationary`、`.transient`、`.ignoresCycle`。 6. 把 `DismissibleHostingView(ContentView)` 放入 panel。 7. `orderFront` 后刷新 Dock/activation policy。 8. 触发 `.tagLauncherOverlayDidShow`,`ContentView` 由此重置临时拖拽态并刷新 App library。 隐藏流程: 1. `OverlayWindowController.hide(force:discardWindow:)` 先检查 `canHideOverlay`,编辑态下默认不允许误关。 2. 调用 `TagDatabase.flushPendingCategorySchemeBackupBatch()`。 3. 如果 Settings 是 child window,先 detach。 4. 关闭 Quick Search mouse monitor、overlay key monitor。 5. 清空 contentView,关闭 panel,并把 `window=nil`。 6. 刷新 chrome,触发 `.tagLauncherOverlayDidHide`。 ## Quick Search 两种入口 Quick Search 有三种 source: - `mainOverlay`:App Grid 已打开时,用户按 Space。 - `globalVisible`:overlay 已可见时,用户按 `Fn+Space`。 - `globalHidden`:overlay 不可见时,用户按 `Fn+Space`,只显示搜索面板,不渲染 App Grid 背景。 `globalHidden` 是最复杂路径。它会: 1. 在 `AppDelegate.showQuickSearchFromGlobalHotkey` 中设置 `isQuickSearchOpen=true`、`quickSearchShouldHideOverlayOnClose=true`、`quickSearchOnlyOverlaySession=true`。 2. 调用 `showOverlay(initialQuickSearchSource: globalHidden)` 创建 overlay。 3. `ContentView.init` 依据 initial source 直接进入 Quick Search 可见状态。 4. `shouldRenderAppGridBehindQuickSearch=false`,避免只想搜索时 App Grid 被带出。 5. Quick Search 关闭后,通知 `AppDelegate` 同步隐藏 overlay,并可 `discardWindow`,降低全屏/Split View 下残留窗口风险。 ## Settings 与 overlay 的关系 Settings 是用户可直接交互的普通 `NSWindow`,但在 overlay 打开时需要保持在 overlay 上方,并且不触发 Space 跳转。 `AppDelegate.prepareSettingsWindow` 做以下约束: - 固定窗口尺寸为 1000x480,避免系统 Navigation Tab Bar 折叠。 - 如果 overlay 可见,则居中到 overlay,并 `overlayWindow.addChildWindow(settingsWindow, ordered: .above)`。 - Settings level 跟随 overlay level。 - 如果当前 overlay 避免 Space 切换,Settings 也移除 `.moveToActiveSpace`,并使用 `.fullScreenAuxiliary`、`.stationary`、`.transient`、`.ignoresCycle`。 - 打开 Settings 前会 `dismissQuickSearchIfNeeded`,防止搜索面板和设置窗口抢 key window。 Settings 关闭时会 detach,并在 overlay 仍可见时延迟 refocus overlay。 ## Backdrop 与 modal 抑制 `DismissibleHostingView.mouseDown` 是 SwiftUI 外的一层 AppKit 兜底。它判断点击是否落在 hosting view 背景: - 普通背景点击:调用 `onBackdropTap`,隐藏 overlay。 - Quick Search 可见时背景点击:发送 `.tagLauncherQuickSearchDismissRequested`,先关 Quick Search。 - Smart Start、拖拽确认、备注编辑等 modal 交互活跃时:通过 `.tagLauncherModalInteractionChanged` 抑制 backdrop 关闭。 - 文本框或浮动按钮被 SwiftUI 包装时:递归寻找真实 AppKit 子视图,转发 mouseDown,保证可点击。 这套逻辑是 7.8.24 修复“Smart Start OK 可能关闭整个 App Grid”的关键上下文,后续不可轻易绕开。 ## Dock 与 activation policy `showDockIcon` 用户设置决定常态 Dock 策略,但窗口显示期间会动态切换: - 用户显示 Dock 图标时,常态 `.regular`。 - 用户隐藏 Dock 图标时,常态 `.accessory`。 - overlay 可见时,presentation options 设为 `.hideDock`,减少 Dock 干扰。 - 全屏/Split View 或 hidden-Dock Quick Search-only 场景下,尽量保持 `.accessory`,避免 Space 跳转和 Dock 图标闪现。 - 需要 Settings 或普通 overlay 前台输入时,通过 `claimLauncherForeground` 抢回 key/front。 Dock 点击打开 App Grid 不是简单 `applicationShouldHandleReopen`。代码要求: 1. `showDockIcon=true`。 2. 不在重复实例 handoff 抑制窗口内。 3. 最近 1.2 秒内有真实鼠标点击。 4. 鼠标位置靠近 Dock 区域。 这样可以阻止重复启动或程序化激活误唤出 App Grid。 ## 全屏与 Split View 判定 `OverlayWindowController.hasFullscreenWindowOnScreen` 使用 WindowServer 窗口列表判断: - `isSingleFullscreenWindow`:宽度接近屏幕宽度、高度至少 88%、水平居中、顶部对齐。 - `hasSplitViewFullscreenWindows`:筛出高而窄的 layer 0 外部窗口,按 X 排序,检查组合后是否覆盖屏幕宽度和高度。 一旦目标屏幕有全屏或 Split View,overlay 进入 `avoidsSpaceSwitch=true`。后续 Quick Search、Settings、chrome 刷新都会沿用这个状态。 ## 必跑 QA 窗口相关改动应优先运行: - `bash Scripts/window_logic_qa.sh` 该脚本覆盖: - overlay layer、menubar/Dock 关系。 - Quick Search overlay + panel 栈。 - Settings over overlay。 - 文件面板 over Settings。 - Force Quit 高于 TagLauncher 所有窗口。 - 多屏幕时 overlay 跟随光标所在屏幕。 - Quick Search 第一次 Esc 关闭搜索、第二次 Esc 关闭 App Grid。 - no-overlay 关闭状态。 - fullscreen overlay / settings 保持在目标全屏 Space。 - Split View 几何识别。 - Dock 图标显示/隐藏和重复实例。 如果只是改 Quick Search 打开/关闭时序,也应结合 `Scripts/quick_search_app_name_qa.sh` 或人工检查 `Fn+Space` 连续触发、立即输入、Esc、鼠标点击候选区。 ## 修改风险提示 - 不要把 Quick Search 重新塞回 SwiftUI overlay 内部普通 view;当前独立 `NSPanel` 是为了解决全屏/Split View 层级和焦点稳定性。 - 不要移除 `quickSearchOnlySession` / `quickSearchShouldHideOverlayOnClose` 区分;否则 `Fn+Space` 可能重新带出 App Grid。 - 不要破坏 App Grid 内按 Space 的 `mainOverlay` 语义;Quick Search 关闭后必须回到 App Grid。 - 不要在 modal overlay 活跃时允许 backdrop 关闭;会复发确认按钮关闭整个 App Grid 的问题。 - 不要让 overlay、Quick Search、Settings 或文件面板遮住系统 Force Quit 窗口。 - 不要把多屏幕定位退化为主屏或菜单栏屏优先;产品口径是优先跟随当前光标所在屏幕。 - 不要只用最终窗口状态验证;窗口类问题必须覆盖“打开瞬间、连续触发、关闭后恢复”。