feat(app): window menu checkmarks with panel toggle, OFX standard search paths

- 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.
This commit is contained in:
2026-08-20 16:20:49 +08:00
parent 877f577564
commit d7cbeba850
4 changed files with 486 additions and 57 deletions
+12
View File
@@ -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-relativeOlive 对齐) | `../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 导入)与
+182 -23
View File
@@ -23,13 +23,21 @@
//! HS: ofxhImageEffectAPI.cpp:150-238load/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<PathBuf> {
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<PathBuf> {
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<PathBuf>, 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<PathBuf> = 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);
}
}
+1 -1
Submodule gpui updated: 0b3e30c095...f7a00fdd91
+291 -33
View File
@@ -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<E: AppEngine> OakApp<E> {
// 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<E: AppEngine> OakApp<E> {
.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<E: AppEngine> OakApp<E> {
}
}
/// Focuses a dock panel (used by the 窗口 menu).
fn focus_panel(&self, id: gpui::dock::PanelId, cx: &mut Context<Self>) {
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<Self>) {
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<Self>) {
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<PanelHandle> {
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<E: AppEngine> OakApp<E> {
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<E: AppEngine> Render for OakApp<E> {
}
}
/// 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<DropTarget> {
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> {
),
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<MenuItem> = 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,