From d7cbeba85010bc06a1d248fad51ba0f954cab115 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Thu, 20 Aug 2026 16:20:49 +0800 Subject: [PATCH] feat(app): window menu checkmarks with panel toggle, OFX standard search paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The Window menu lists every panel, checks the open ones, and toggles visibility on click (closed panels reopen at their last dock target, falling back to the default group) — a panel closed by accident (e.g. the inspector) is one menu click away again. The menu refreshes on dock structure events. - OFX plugin scanning now covers the full standard location set: per-user (~/.OFX/Plugins, ~/.local/share, ~/Library/OFX/Plugins on macOS), system-level (/Library/OFX/Plugins, /usr/OFX/Plugins, /usr/local, %ProgramFiles%\Common Files\OFX\Plugins), app-relative, and the OFX_PLUGIN_PATH environment variable. --- crates/oakplugin/README.md | 12 ++ crates/oakplugin/src/host.rs | 205 +++++++++++++++++++--- gpui | 2 +- src/app.rs | 324 +++++++++++++++++++++++++++++++---- 4 files changed, 486 insertions(+), 57 deletions(-) diff --git a/crates/oakplugin/README.md b/crates/oakplugin/README.md index c6e7e2e7b..992c78a24 100644 --- a/crates/oakplugin/README.md +++ b/crates/oakplugin/README.md @@ -34,6 +34,18 @@ src/ render.rs oakrender 桥:Texture/Frame/Renderer 值类型调用面 ``` +## 插件搜索路径 + +`host::PluginCache::scan` 按以下顺序扫描(重复路径去重、不存在的目录 +跳过;bundle 递归扫描,深度 3): + +| 层级 | 路径 | +| --- | --- | +| 用户级(`$HOME`) | `$HOME/.OFX/Plugins`、`$HOME/.local/share/OFX/Plugins`、`$HOME/.local/share/olive/ofx/Plugins`;macOS 另含 `$HOME/Library/OFX/Plugins` | +| 系统级(OFX 规范) | macOS `/Library/OFX/Plugins`;Linux `/usr/OFX/Plugins`、`/usr/local/OFX/Plugins`;Windows `%ProgramFiles%\Common Files\OFX\Plugins`(`%ProgramFiles%` 未设置时回退 `C:\Program Files\Common Files\OFX\Plugins`) | +| app-relative(Olive 对齐) | `../OFX/Plugins`、`../share/olive/ofx/Plugins`、`../lib/olive/ofx/Plugins` | +| 环境变量 | `OFX_PLUGIN_PATH`(OFX 官方)、`OLIVE_OFX_PLUGIN_PATH`、`OLIVE_PLUGIN_PATH`(Olive 扩展);平台路径分隔符(Unix `:` / Windows `;`) | + ## 桥布局决策(M11 第 1 期冻结;第 2 期增补;单库化修订) 单库化(single-lib unification)后 `bridge/`(C ABI 导入)与 diff --git a/crates/oakplugin/src/host.rs b/crates/oakplugin/src/host.rs index a85dbead4..02eff41e9 100644 --- a/crates/oakplugin/src/host.rs +++ b/crates/oakplugin/src/host.rs @@ -23,13 +23,21 @@ //! HS: ofxhImageEffectAPI.cpp:150-238(load/describe/ //! describeInContext 的 action 序列)。 //! -//! ## 扫描语义(对照 olivehost.cpp:118-191) +//! ## 扫描语义(对照 olivehost.cpp:118-191 + OFX 官方规范) //! -//! 默认路径集:`$HOME/.OFX/Plugins`、`$HOME/.local/share/OFX/Plugins`、 -//! `$HOME/.local/share/olive/ofx/Plugins`、`../OFX/Plugins`、 -//! `../share/olive/ofx/Plugins`、`../lib/olive/ofx/Plugins`,以及 -//! `OLIVE_OFX_PLUGIN_PATH`/`OLIVE_PLUGIN_PATH`/`OFX_PLUGIN_PATH` -//! 环境变量(':' 分隔);重复扫描按路径去重。 +//! 默认路径集按平台组织: +//! 用户级(`$HOME` 非空时)——`$HOME/.OFX/Plugins`、 +//! `$HOME/.local/share/OFX/Plugins`、`$HOME/.local/share/olive/ofx/Plugins` +//! (macOS 另含 `$HOME/Library/OFX/Plugins`); +//! 系统级——macOS `/Library/OFX/Plugins`,Linux `/usr/OFX/Plugins`、 +//! `/usr/local/OFX/Plugins`,Windows `%ProgramFiles%\Common Files\OFX\Plugins` +//! (`%ProgramFiles%` 未设置时回退 `C:\Program Files\...`); +//! Olive app-relative——`../OFX/Plugins`、`../share/olive/ofx/Plugins`、 +//! `../lib/olive/ofx/Plugins`(olivehost.cpp:104-107); +//! 以及 `OFX_PLUGIN_PATH`(OFX 官方)与 `OLIVE_OFX_PLUGIN_PATH`/ +//! `OLIVE_PLUGIN_PATH`(Olive 扩展)三个环境变量(平台路径分隔符: +//! Unix ':' / Windows ';')。不存在的目录直接跳过;重复扫描按规范化 +//! 路径去重。 //! `// [P2]`:Info.plist 解析(CFBundleExecutable)不做——bundle 内 //! 二进制以探测方式定位(`Contents/MacOS/*`、`Contents/Linux-*/*`、 //! 根下 `*.so`/`*.dylib`)。 @@ -624,28 +632,88 @@ fn find_binary_in_bundle(bundle: &Path) -> Option { None } -impl PluginCache { - /// 扫描标准目录(M9 行为:环境变量 + 用户/系统默认路径集,顺序 - /// 与 olivehost.cpp:118-191 一致)。重复调用是 no-op(按路径 - /// 去重)。 - pub fn scan(&self) -> crate::error::Result<()> { - let home = std::env::var("HOME").ok().unwrap_or_default(); - let mut paths = Vec::new(); - if !home.is_empty() { - paths.push(PathBuf::from(&home).join(".OFX/Plugins")); - paths.push(PathBuf::from(&home).join(".local/share/OFX/Plugins")); - paths.push(PathBuf::from(&home).join(".local/share/olive/ofx/Plugins")); +/// 默认插件搜索路径(扫描顺序):OFX 规范的用户/系统级位置(按平台 +/// cfg 组织)、Olive app-relative 位置(C++ 对齐,olivehost.cpp:104-107)。 +/// +/// OFX 官方标准位置(见 OFX "plug-in discovery" 规范): +/// +/// * macOS:用户 `~/Library/OFX/Plugins`、系统 `/Library/OFX/Plugins` +/// * Windows:`%ProgramFiles%\Common Files\OFX\Plugins`(`%ProgramFiles%` +/// 未设置时回退字面 `C:\Program Files\...`) +/// * Linux:`/usr/OFX/Plugins`、`/usr/local/OFX/Plugins` +/// +/// 外加 Olive 的历史位置:`$HOME/.OFX/Plugins`、 +/// `$HOME/.local/share/OFX/Plugins`、`$HOME/.local/share/olive/ofx/Plugins`、 +/// `../OFX/Plugins`、`../share/olive/ofx/Plugins`、`../lib/olive/ofx/Plugins`。 +/// 环境变量(`OFX_PLUGIN_PATH`/`OLIVE_OFX_PLUGIN_PATH`/`OLIVE_PLUGIN_PATH`) +/// 由 [`PluginCache::scan`] 追加。`home` 为 `$HOME`;`None` 时跳过所有 +/// home 基路径。 +fn default_plugin_paths(home: Option<&Path>) -> Vec { + let mut paths = Vec::new(); + if let Some(home) = home { + paths.push(home.join(".OFX/Plugins")); + paths.push(home.join(".local/share/OFX/Plugins")); + paths.push(home.join(".local/share/olive/ofx/Plugins")); + // OFX 规范:macOS 用户级位置。 + #[cfg(target_os = "macos")] + paths.push(home.join("Library/OFX/Plugins")); + } + // OFX 规范:macOS 系统级位置。 + #[cfg(target_os = "macos")] + paths.push(PathBuf::from("/Library/OFX/Plugins")); + // OFX 规范:Windows 系统级位置(优先真实安装根 `%ProgramFiles%`)。 + #[cfg(target_os = "windows")] + { + match std::env::var("ProgramFiles") + .or_else(|_| std::env::var("ProgramW6432")) + .map(PathBuf::from) + { + Ok(root) => paths.push(root.join("Common Files").join("OFX").join("Plugins")), + Err(_) => paths.push(PathBuf::from(r"C:\Program Files\Common Files\OFX\Plugins")), } - paths.push(PathBuf::from("../OFX/Plugins")); - paths.push(PathBuf::from("../share/olive/ofx/Plugins")); - paths.push(PathBuf::from("../lib/olive/ofx/Plugins")); + } + // OFX 规范:Linux 系统级位置。 + #[cfg(target_os = "linux")] + { + paths.push(PathBuf::from("/usr/OFX/Plugins")); + paths.push(PathBuf::from("/usr/local/OFX/Plugins")); + } + // Olive app-relative 位置(olivehost.cpp:104-107)。 + paths.push(PathBuf::from("../OFX/Plugins")); + paths.push(PathBuf::from("../share/olive/ofx/Plugins")); + paths.push(PathBuf::from("../lib/olive/ofx/Plugins")); + paths +} + +/// 把 `canonical` 记入已扫描路径表,返回 `false` 时表示已存在(调用方 +/// 跳过再次扫描)。保持首见顺序;去重键是规范化后的路径(HS: +/// addFileToPath 的 weakly_canonical 语义)。 +fn record_scanned_path(seen: &mut Vec, canonical: PathBuf) -> bool { + if seen.iter().any(|p| p == &canonical) { + return false; + } + seen.push(canonical); + true +} + +impl PluginCache { + /// 扫描标准目录(OFX 规范 + C++ 对齐):用户级 `$HOME` 路径、平台 + /// 系统级路径(macOS/Linux/Windows)、Olive app-relative 路径,以及 + /// `OFX_PLUGIN_PATH`/`OLIVE_OFX_PLUGIN_PATH`/`OLIVE_PLUGIN_PATH` 三个 + /// 环境变量。重复调用是 no-op(按规范化路径去重);不存在的目录 + /// 直接跳过(见 [`Self::scan_path`])。 + pub fn scan(&self) -> crate::error::Result<()> { + let home = std::env::var("HOME").ok().filter(|h| !h.is_empty()); + let mut paths = default_plugin_paths(home.as_deref().map(Path::new)); for var in [ "OLIVE_OFX_PLUGIN_PATH", "OLIVE_PLUGIN_PATH", "OFX_PLUGIN_PATH", ] { if let Ok(raw) = std::env::var(var) { - paths.extend(raw.split(':').filter(|p| !p.is_empty()).map(PathBuf::from)); + // 平台路径分隔符(Unix ':' / Windows ';'),与 C++ 的 + // `QDir::listSeparator` 一致。 + paths.extend(std::env::split_paths(&raw)); } } for p in paths { @@ -660,10 +728,9 @@ impl PluginCache { let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); { let mut seen = self.scanned_paths.lock().unwrap_or_else(|e| e.into_inner()); - if seen.iter().any(|p| p == &canonical) { + if !record_scanned_path(&mut seen, canonical.clone()) { return Ok(()); } - seen.push(canonical.clone()); } if !canonical.is_dir() { return Ok(()); @@ -1230,3 +1297,95 @@ fn init_instance_props(props: &PropertySet, instance: &Instance) { props.set_one(PROP_COLOUR_CONFIG, Value::String(cs(NATIVE_CONFIG_ID))); props.set_one(PROP_OCIO_CONFIG, Value::String(cs(OCIO_CONFIG_URI))); } + +#[cfg(test)] +mod tests { + use super::*; + + /// The default path table includes every home- and app-relative location + /// plus the platform-gated OFX standard locations. + #[test] + fn default_paths_cover_home_and_standard_locations() { + let home = Path::new("/home/octa"); + let paths = default_plugin_paths(Some(home)); + // Olive legacy + home locations. + assert!(paths.contains(&home.join(".OFX/Plugins"))); + assert!(paths.contains(&home.join(".local/share/OFX/Plugins"))); + assert!(paths.contains(&home.join(".local/share/olive/ofx/Plugins"))); + // App-relative (C++ parity). + assert!(paths.contains(&PathBuf::from("../OFX/Plugins"))); + assert!(paths.contains(&PathBuf::from("../share/olive/ofx/Plugins"))); + assert!(paths.contains(&PathBuf::from("../lib/olive/ofx/Plugins"))); + // OFX standard locations, gated by platform. + #[cfg(target_os = "macos")] + { + assert!(paths.contains(&home.join("Library/OFX/Plugins"))); + assert!(paths.contains(&PathBuf::from("/Library/OFX/Plugins"))); + } + #[cfg(target_os = "linux")] + { + assert!(paths.contains(&PathBuf::from("/usr/OFX/Plugins"))); + assert!(paths.contains(&PathBuf::from("/usr/local/OFX/Plugins"))); + } + #[cfg(target_os = "windows")] + { + assert!(paths + .iter() + .any(|p| p.ends_with("Common Files/OFX/Plugins") || p.ends_with("Common Files\\OFX\\Plugins"))); + } + } + + /// With no `$HOME` the home-based entries are skipped; the rest remain. + #[test] + fn default_paths_skip_home_entries_when_home_unset() { + let paths = default_plugin_paths(None); + assert!(!paths.iter().any(|p| p.to_string_lossy().starts_with("/home/"))); + assert!(paths.contains(&PathBuf::from("../OFX/Plugins"))); + assert_eq!( + paths + .iter() + .filter(|p| p.to_string_lossy().contains(".OFX")) + .count(), + 0 + ); + } + + /// The dedupe record rejects an already-seen path and keeps first-seen + /// order. + #[test] + fn record_scanned_path_dedupes() { + let mut seen: Vec = Vec::new(); + assert!(record_scanned_path(&mut seen, PathBuf::from("/a/Plugins"))); + assert!(record_scanned_path(&mut seen, PathBuf::from("/b/Plugins"))); + // A duplicate is rejected without being recorded again. + assert!(!record_scanned_path(&mut seen, PathBuf::from("/a/Plugins"))); + assert_eq!(seen, vec![PathBuf::from("/a/Plugins"), PathBuf::from("/b/Plugins")]); + } + + /// Scanning an existing directory twice only records it once, and a + /// non-existent directory is skipped without error. + #[test] + fn scan_path_skips_missing_and_dedupes_existing() { + let dir = std::env::temp_dir().join(format!("oakplugin-scan-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let cache = PluginCache { + plugins: Mutex::new(Vec::new()), + scanned_paths: Mutex::new(Vec::new()), + binaries: Mutex::new(Vec::new()), + }; + cache.scan_path(&dir).expect("existing dir scans"); + cache.scan_path(&dir).expect("duplicate scan is a no-op"); + { + let seen = cache.scanned_paths.lock().unwrap_or_else(|e| e.into_inner()); + assert_eq!(seen.len(), 1, "the same directory is scanned only once"); + } // drop the guard before scanning again + + // A missing directory is skipped (no error, no plugin scan). + let missing = dir.join("does-not-exist"); + cache.scan_path(&missing).expect("missing dir scan is a no-op"); + let plugins = cache.plugins.lock().unwrap_or_else(|e| e.into_inner()); + assert!(plugins.is_empty(), "no bundles were loaded"); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/gpui b/gpui index 0b3e30c09..f7a00fdd9 160000 --- a/gpui +++ b/gpui @@ -1 +1 @@ -Subproject commit 0b3e30c0952f8ba086910ffc562996a3687522a8 +Subproject commit f7a00fdd911e17a7fdaf4d6b5c888ae2b59a7021 diff --git a/src/app.rs b/src/app.rs index 4c78d81d5..7785c8628 100644 --- a/src/app.rs +++ b/src/app.rs @@ -98,6 +98,16 @@ pub(crate) mod menu_ids { pub const LANG_ZH: usize = ActionId::LangZh.menu_id(); pub const LANG_EN: usize = ActionId::LangEn.menu_id(); pub const PREFERENCES: usize = ActionId::Preferences.menu_id(); + + pub const FOCUS_PROJECT: usize = ActionId::FocusProject.menu_id(); + pub const FOCUS_SOURCE_VIEWER: usize = ActionId::FocusSourceViewer.menu_id(); + pub const FOCUS_PROGRAM_VIEWER: usize = ActionId::FocusProgramViewer.menu_id(); + pub const FOCUS_NODE_EDITOR: usize = ActionId::FocusNodeEditor.menu_id(); + pub const FOCUS_INSPECTOR: usize = ActionId::FocusInspector.menu_id(); + pub const FOCUS_HISTORY: usize = ActionId::FocusHistory.menu_id(); + pub const FOCUS_TIMELINE: usize = ActionId::FocusTimeline.menu_id(); + pub const FOCUS_EFFECT_LIBRARY: usize = ActionId::FocusEffectLibrary.menu_id(); + pub const FOCUS_MULTICAM: usize = ActionId::FocusMulticam.menu_id(); } /// Modal-dialog control ids (see [`ModalEvent::control`]). @@ -584,10 +594,19 @@ impl OakApp { // Track the focused panel: the dock re-emits every panel's // `PanelEvent::Focused` as `DockEvent::PanelFocused` (and its own // `focus_panel` calls), so a single subscription keeps the shell's - // focused-panel routing target up to date. - cx.subscribe(&dock, |this, _dock, event: &DockEvent, _cx| { - if let DockEvent::PanelFocused(id) = event { - this.focused_panel = Some(*id); + // focused-panel routing target up to date. Structural changes (a panel + // closed via its tab ✕, torn off, or re-docked) refresh the 窗口 menu's + // checkmarks, which read the dock's live visible-panel set. + cx.subscribe(&dock, |this, _dock, event: &DockEvent, cx| { + match event { + DockEvent::PanelFocused(id) => this.focused_panel = Some(*id), + DockEvent::PanelAdded(_) + | DockEvent::PanelRemoved(_) + | DockEvent::PanelClosed(_) => { + this.rebuild_menu_bar(cx); + cx.notify(); + } + _ => {} } }) .detach(); @@ -995,15 +1014,18 @@ impl OakApp { .update(cx, |engine, cx| engine.remove_marker_at_playhead(cx)), A::SetWorkArea => self.set_workarea_from_selection(cx), // --- Window ---------------------------------------------------- - A::FocusProject => self.focus_panel(PROJECT, cx), - A::FocusSourceViewer => self.focus_panel(SOURCE_VIEWER, cx), - A::FocusProgramViewer => self.focus_panel(PROGRAM_VIEWER, cx), - A::FocusNodeEditor => self.focus_panel(NODE_EDITOR, cx), - A::FocusInspector => self.focus_panel(INSPECTOR, cx), - A::FocusHistory => self.focus_panel(HISTORY, cx), - A::FocusTimeline => self.focus_panel(TIMELINE, cx), - A::FocusEffectLibrary => self.focus_panel(EFFECT_LIBRARY, cx), - A::FocusMulticam => self.focus_panel(MULTICAM, cx), + // The 窗口 menu toggles each panel: clicking an open panel closes + // it (through the dock's remove flow, the same path the tab ✕ + // takes), clicking a closed one re-opens it. + A::FocusProject => self.toggle_panel(PROJECT, cx), + A::FocusSourceViewer => self.toggle_panel(SOURCE_VIEWER, cx), + A::FocusProgramViewer => self.toggle_panel(PROGRAM_VIEWER, cx), + A::FocusNodeEditor => self.toggle_panel(NODE_EDITOR, cx), + A::FocusInspector => self.toggle_panel(INSPECTOR, cx), + A::FocusHistory => self.toggle_panel(HISTORY, cx), + A::FocusTimeline => self.toggle_panel(TIMELINE, cx), + A::FocusEffectLibrary => self.toggle_panel(EFFECT_LIBRARY, cx), + A::FocusMulticam => self.toggle_panel(MULTICAM, cx), // --- Tools ----------------------------------------------------- A::Snapping => { let enabled = !self.timeline.read(cx).state.snap_enabled; @@ -1256,13 +1278,60 @@ impl OakApp { } } - /// Focuses a dock panel (used by the 窗口 menu). - fn focus_panel(&self, id: gpui::dock::PanelId, cx: &mut Context) { - if let Some(handle) = cx.windows().first() { + /// 窗口 menu toggle: clicking a listed panel opens it if it is closed and + /// closes it if it is open. Docked panels are removed through the dock's + /// remove flow — the same path the tab ✕ button takes (see + /// [`DockArea::remove_panel`]) — so the close is symmetrical and the + /// position is recorded for a later reopen. Floating (tear-off) panels + /// have their window closed for good. Rebuilds the menu bar so the + /// checkmarks follow. + fn toggle_panel(&mut self, id: gpui::dock::PanelId, cx: &mut Context) { + if self.dock.read(cx).is_floating(id) { let dock = self.dock.clone(); - let _ = cx.update_window(*handle, move |_root, window, app| { - dock.update(app, |dock, cx| dock.focus_panel(id, window, cx)); + dock.update(cx, |dock, cx| dock.close_floating(id, cx)); + } else if self.dock.read(cx).is_docked(id) { + let dock = self.dock.clone(); + dock.update(cx, |dock, cx| { + let _ = dock.remove_panel(id, cx); }); + } else { + self.reopen_panel(id, cx); + } + self.rebuild_menu_bar(cx); + cx.notify(); + } + + /// Re-opens a closed panel, preferring its last docked position when the + /// anchor panel is still present, then falling back to the design's + /// default placement. The panel entity is reused, so its per-panel state + /// survives the close/reopen round trip. + fn reopen_panel(&mut self, id: gpui::dock::PanelId, cx: &mut Context) { + let Some(handle) = self.panel_handle(id, cx) else { + return; + }; + let dock = self.dock.clone(); + dock.update(cx, |dock, cx| { + let target = dock.last_target(id).or_else(|| default_dock_target(id)); + let target = dock.fallback_target(target); + let _ = dock.add_panel(handle, target, cx); + }); + cx.notify(); + } + + /// Wraps the shell's pre-built panel entity for `id` in a fresh + /// [`PanelHandle`], so a reopened panel keeps its live state. + fn panel_handle(&self, id: gpui::dock::PanelId, cx: &App) -> Option { + match id { + PROJECT => Some(PanelHandle::new(self.panels.project.clone(), cx)), + SOURCE_VIEWER => Some(PanelHandle::new(self.panels.source_viewer.clone(), cx)), + PROGRAM_VIEWER => Some(PanelHandle::new(self.panels.program_viewer.clone(), cx)), + NODE_EDITOR => Some(PanelHandle::new(self.panels.node_editor.clone(), cx)), + INSPECTOR => Some(PanelHandle::new(self.panels.inspector.clone(), cx)), + HISTORY => Some(PanelHandle::new(self.panels.history.clone(), cx)), + TIMELINE => Some(PanelHandle::new(self.panels.timeline.clone(), cx)), + EFFECT_LIBRARY => Some(PanelHandle::new(self.panels.effect_library.clone(), cx)), + MULTICAM => Some(PanelHandle::new(self.panels.multicam.clone(), cx)), + _ => None, } } @@ -1290,6 +1359,10 @@ impl OakApp { show_all: self.show_all, full_screen: self.full_screen, use_proxy_media: self.engine.read(cx).use_proxy_media(), + // The 窗口 menu's checkmarks mirror the dock's live visible panel + // set (docked or floating), so a panel closed via its tab ✕ (or + // torn off) loses its checkmark on the next rebuild. + open_panels: open_panels_mask(self.dock.read(cx)), }; let Ok(menu_bar) = cx.update_window(*handle, |_root, window, app| { app.new(|cx| MenuBar::new(1, make_menus(state), window, cx)) @@ -2168,9 +2241,63 @@ impl Render for OakApp { } } +/// The nine dockable panels in the 窗口 menu's order, each with the registry +/// action that toggles it. +const WINDOW_PANELS: [(PanelId, crate::actions::ActionId); 9] = [ + (PROJECT, crate::actions::ActionId::FocusProject), + (SOURCE_VIEWER, crate::actions::ActionId::FocusSourceViewer), + (PROGRAM_VIEWER, crate::actions::ActionId::FocusProgramViewer), + (NODE_EDITOR, crate::actions::ActionId::FocusNodeEditor), + (INSPECTOR, crate::actions::ActionId::FocusInspector), + (HISTORY, crate::actions::ActionId::FocusHistory), + (TIMELINE, crate::actions::ActionId::FocusTimeline), + (EFFECT_LIBRARY, crate::actions::ActionId::FocusEffectLibrary), + (MULTICAM, crate::actions::ActionId::FocusMulticam), +]; + +/// The bit for `panel` in [`MenuState::open_panels`] (one bit per `PanelId`). +fn panel_bit(id: PanelId) -> u16 { + 1u16 << (id.raw() as u16) +} + +/// The design's default dock position for each panel, mirroring the seeding +/// in [`OakApp::new`] — 项目 | 素材查看器 | 序列查看器+节点编辑器 | +/// 检查器+历史记录 row, timeline full width at the bottom. Used to re-open a +/// closed panel when its last known position's anchor is gone. +fn default_dock_target(id: PanelId) -> Option { + let t = |panel: PanelId, zone: DropZone| DropTarget { + panel: Some(panel), + zone, + }; + match id { + PROJECT => None, + EFFECT_LIBRARY => Some(t(PROJECT, DropZone::Center)), + SOURCE_VIEWER => Some(t(PROJECT, DropZone::Right)), + PROGRAM_VIEWER => Some(t(SOURCE_VIEWER, DropZone::Right)), + NODE_EDITOR => Some(t(PROGRAM_VIEWER, DropZone::Center)), + INSPECTOR => Some(t(PROGRAM_VIEWER, DropZone::Right)), + HISTORY => Some(t(INSPECTOR, DropZone::Center)), + TIMELINE => Some(DropTarget { + panel: None, + zone: DropZone::Bottom, + }), + MULTICAM => Some(t(PROGRAM_VIEWER, DropZone::Center)), + _ => Some(t(PROJECT, DropZone::Center)), + } +} + +/// The 窗口 menu's open-panel bitmask for `dock`'s current visible panel set +/// (docked or floating). Shared by [`OakApp::rebuild_menu_bar`] and tests. +fn open_panels_mask(dock: &DockArea) -> u16 { + WINDOW_PANELS + .iter() + .map(|(id, _)| if dock.is_panel_visible(*id) { panel_bit(*id) } else { 0 }) + .fold(0, |a, b| a | b) +} + /// The dynamic menu-bar state the registry-driven menu tree reads its /// checkmarks from (theme, active tool, snapping, loop, show-all, -/// full-screen). +/// full-screen, and which dock panels are open). #[derive(Clone, Copy)] struct MenuState { dark: bool, @@ -2180,11 +2307,16 @@ struct MenuState { show_all: bool, full_screen: bool, use_proxy_media: bool, + /// Bitmask of the dock panels currently visible (docked or floating), one + /// bit per [`PanelId`] via [`panel_bit`]. Read from the live dock area at + /// menu-bar build time. + open_panels: u16, } impl MenuState { /// The shell's startup state (the timeline widget defaults to snapping - /// on, the pointer tool is active). + /// on, the pointer tool is active; every panel starts docked, so all bits + /// are set). fn new(dark: bool) -> Self { Self { dark, @@ -2196,6 +2328,10 @@ impl MenuState { use_proxy_media: oakcommon::configstore::ConfigStore::instance() .get_bool(None, "UseProxyMedia", 1) != 0, + open_panels: WINDOW_PANELS + .iter() + .map(|(id, _)| panel_bit(*id)) + .fold(0, |a, b| a | b), } } } @@ -2386,19 +2522,23 @@ fn make_menus(state: MenuState) -> Vec { ), MenuBarEntry::new( tr("menu.window"), - Menu::new(vec![ - menu_item(A::FocusProject), - menu_item(A::FocusSourceViewer), - menu_item(A::FocusProgramViewer), - menu_item(A::FocusNodeEditor), - menu_item(A::FocusInspector), - menu_item(A::FocusHistory), - menu_item(A::FocusTimeline), - menu_item(A::FocusEffectLibrary), - menu_item(A::FocusMulticam).separated(), - menu_item(A::MaximizePanel), - menu_item(A::ResetDefaultLayout), - ]), + Menu::new({ + // Every panel is listed, checked when it is currently visible + // in the dock (docked or floating); clicking toggles it open + // or closed. + let mut window_items: Vec = WINDOW_PANELS + .iter() + .map(|(id, action)| { + menu_item(*action).with_checked(state.open_panels & panel_bit(*id) != 0) + }) + .collect(); + if let Some(last) = window_items.last_mut() { + last.separator_after = true; + } + window_items.push(menu_item(A::MaximizePanel)); + window_items.push(menu_item(A::ResetDefaultLayout)); + window_items + }), ), MenuBarEntry::new(tr("menu.tools"), Menu::new(tools)), MenuBarEntry::new( @@ -2687,6 +2827,124 @@ mod tests { assert_eq!(dark_item(false).checked, Some(false)); } + /// The 窗口 menu lists every dockable panel, and each item's checkmark + /// mirrors the `open_panels` bitmask carried in [`MenuState`] (which the + /// shell reads from the dock's live visible-panel set). + #[test] + fn window_menu_lists_all_panels_and_checks_the_open_ones() { + let _guard = crate::actions::shortcuts_test_lock().lock().unwrap(); + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); + crate::i18n::set_language(crate::i18n::Language::EnUs); + + let mask = panel_bit(INSPECTOR) | panel_bit(TIMELINE); + let mut state = MenuState::new(true); + state.open_panels = mask; + let window = make_menus(state) + .into_iter() + .find(|entry| entry.title == crate::i18n::tr("menu.window")) + .expect("Window menu exists") + .menu; + + for (panel, action) in WINDOW_PANELS { + let item = window + .items + .iter() + .find(|item| item.id == action.menu_id()) + .unwrap_or_else(|| panic!("Window menu is missing panel {}", panel.raw())); + assert_eq!( + item.checked, + Some(mask & panel_bit(panel) != 0), + "panel {} checkmark follows open_panels", + panel.raw() + ); + } + } + + /// 窗口 → 检查器 closes the inspector through the dock's remove flow (the + /// same path the tab ✕ takes) and re-opens it from the closed state — the + /// round trip that used to leave a dismissed inspector unrecoverable. + #[gpui::test] + async fn window_menu_toggle_closes_and_reopens_the_inspector(cx: &mut TestAppContext) { + let _guard = crate::actions::shortcuts_test_lock().lock().unwrap(); + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); + let (_window, root) = mock_shell(cx); + + // The checkmark shown in the 窗口 menu for the inspector, derived from + // the dock's current visible-panel set exactly as `rebuild_menu_bar` + // computes it. + let inspector_checked = |app: &App| { + let dock = root.read(app).dock.read(app); + let mut state = MenuState::new(true); + state.open_panels = open_panels_mask(dock); + make_menus(state) + .into_iter() + .find(|e| e.title == crate::i18n::tr("menu.window")) + .expect("Window menu exists") + .menu + .items + .iter() + .find(|item| item.id == ActionId::FocusInspector.menu_id()) + .expect("inspector item exists") + .checked + }; + + // The inspector starts docked and checked. + assert!(cx.read(|app| root.read(app).dock.read(app).is_docked(INSPECTOR))); + assert_eq!(cx.read(|app| inspector_checked(app)), Some(true)); + + // Toggle closed via the menu action: removed from the dock and + // unchecked. + cx.update(|app| root.update(app, |app, cx| app.on_menu(menu_ids::FOCUS_INSPECTOR, cx))); + cx.run_until_parked(); + assert!(!cx.read(|app| root.read(app).dock.read(app).is_docked(INSPECTOR))); + assert!( + !cx.read(|app| root.read(app).dock.read(app).is_panel_visible(INSPECTOR)), + "the inspector is fully closed, not floating" + ); + assert_eq!(cx.read(|app| inspector_checked(app)), Some(false)); + + // Toggle open again: the inspector comes back (its entity is reused, + // so its state survives the close). + cx.update(|app| root.update(app, |app, cx| app.on_menu(menu_ids::FOCUS_INSPECTOR, cx))); + cx.run_until_parked(); + assert!(cx.read(|app| root.read(app).dock.read(app).is_docked(INSPECTOR))); + assert_eq!(cx.read(|app| inspector_checked(app)), Some(true)); + } + + /// Closing a panel via its tab ✕ — the dock's own close flow — also + /// refreshes the 窗口 menu: the dock's structural event rebuilds the menu + /// bar, so the dismissed panel loses its checkmark without waiting for any + /// other rebuild trigger. + #[gpui::test] + async fn closing_a_panel_via_its_tab_unchecks_it_in_the_window_menu( + cx: &mut TestAppContext, + ) { + let _guard = crate::actions::shortcuts_test_lock().lock().unwrap(); + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); + let (window, root) = mock_shell(cx); + + let menu_bar_before = cx.read(|app| root.read(app).menu_bar.entity_id()); + assert!(cx.read(|app| root.read(app).dock.read(app).is_docked(INSPECTOR))); + + // Click the inspector's tab close button. + let mut vcx = VisualTestContext::from_window(window.into(), cx); + let close = vcx + .debug_bounds("dock-tab-close-5") + .expect("inspector tab close button rendered"); + vcx.simulate_click(close.center(), gpui::Modifiers::none()); + drop(vcx); + cx.run_until_parked(); + + // The inspector is gone and the menu bar was rebuilt (new entity) from + // the dock's shrunken visible-panel set. + assert!(!cx.read(|app| root.read(app).dock.read(app).is_docked(INSPECTOR))); + let menu_bar_after = cx.read(|app| root.read(app).menu_bar.entity_id()); + assert_ne!( + menu_bar_after, menu_bar_before, + "the dock's structural event rebuilds the menu bar" + ); + } + /// The File menu exposes the full project lifecycle actions (new / /// open-from-library / open-file / manager / export-project / close / /// export) and the Edit menu the undo stack plus the delete variants,