Files
oak-editor/src/app.rs
T
Mike-Solar cf459d7e4c feat(app): multicam panel with live angle grid, switching, timeline enable
- New MulticamPanel: rows/cols angle grid with the current angle
  highlighted, click-to-switch, 1-9 switch-and-split and cmd-1-9
  switch-only shortcuts (focused-panel routed), deferred switch queue
  during playback.
- src/oakui/multicam.rs: clip->connected-sequence resolution, multicam
  state detection (selection then playhead fallbacks), per-angle frame
  requests rendered through the process backend into an LRU cache.
- Timeline clip context menu Multi-Cam checkable item wired to
  oaktimeline::multicam enable/disable with undo.
- Engine trait extended (real + mock); mock drives the real command
  path with synthesized angle frames.
2026-08-18 21:40:00 +08:00

3338 lines
112 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! The application shell: menu bar, dock layout, status bar, modal dialogs
//! and the tick loop that drives playback, playhead sync, the audio meter
//! and the export progress.
//!
//! The shell is generic over the engine backend ([`AppEngine`]); [`run`]
//! picks the backend at startup: the real engine by default, the mock when
//! the `--mock` flag / `OAK_ENGINE=mock` env var is given or the
//! `mock-engine` cargo feature is enabled.
//!
//! Layout per the design (`design/Oak-UI设计图-主界面-标注版.png`):
//!
//! ```text
//! ┌ menu bar (文件 编辑 视图 回放 序列 窗口 工具 帮助)
//! ├─────────────────────────────────────────────────────
//! │ dock: 项目 | 素材查看器 | 序列查看器+节点编辑器 | 检查器+历史记录
//! │ (vertical split) 时间线 (full width, 31px toolbar on top)
//! ├─────────────────────────────────────────────────────
//! └ status bar: 就绪 | 缓存 | 代理 | 库写入状态 || 时间码/时长 | 帧率 | 分辨率 | 引擎
//! ```
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use gpui::dock::{
DockArea, DockEvent, DockLayout, DropTarget, DropZone, NodePath, PanelHandle, PanelId,
PanelRegistry,
};
use gpui::timeline::{
ClipData, ClipId, Frame, FrameRange, TimelineEvent, TimelineView, TrackData,
};
use gpui::{
div, prelude::*, px, size, App, AsyncWindowContext, Bounds, Context, Entity, PathPromptOptions,
Render, Window, WindowBounds, WindowOptions,
};
use gpui_widgets::audio_meter::{AudioLevelMeter, MeterOrientation};
use gpui_widgets::dialog::progress::{progress_dialog, ProgressContent};
use gpui_widgets::dialog::{DialogButton, Modal, ModalEvent, ModalOptions};
use gpui_widgets::menu::{Menu, MenuBar, MenuBarEntry, MenuBarEvent, MenuItem};
use gpui_widgets::theme::{apply_theme, OakTheme};
use gpui_widgets::viewer::PlaybackClock;
use crate::actions::{ActionId, Tool};
use crate::dialogs::{ExportDialogContent, PreferencesContent};
use crate::oakui::{AppEngine, ExportSession, MockEngine, Monitor, RealEngine};
use crate::panels::commands as panel_commands;
use crate::panels::effect_library::EffectLibraryPanel;
use crate::panels::history::HistoryPanel;
use crate::panels::ids::*;
use crate::panels::inspector::InspectorPanel;
use crate::panels::multicam::MulticamPanel;
use crate::panels::node_editor::NodeEditorPanel;
use crate::panels::program_viewer::ProgramViewerPanel;
use crate::panels::project_explorer::ProjectExplorerPanel;
use crate::panels::source_viewer::SourceViewerPanel;
use crate::panels::status_bar::StatusBar;
use crate::panels::timeline::TimelinePanel;
// Menu item ids: thin aliases over the action registry's menu ids (the
// registry is the single source; these keep the test call sites readable).
#[cfg(test)]
pub(crate) mod menu_ids {
use crate::actions::ActionId;
pub const NEW_PROJECT: usize = ActionId::NewProject.menu_id();
pub const OPEN_PROJECT: usize = ActionId::OpenProject.menu_id();
pub const EXPORT_PROJECT: usize = ActionId::SaveProject.menu_id();
pub const CLOSE: usize = ActionId::CloseProject.menu_id();
pub const EXPORT: usize = ActionId::Export.menu_id();
pub const QUIT: usize = ActionId::Exit.menu_id();
pub const IMPORT_FOOTAGE: usize = ActionId::Import.menu_id();
pub const PROJECT_MANAGER: usize = ActionId::ProjectManager.menu_id();
pub const OPEN_FROM_LIBRARY: usize = ActionId::OpenFromLibrary.menu_id();
pub const UNDO: usize = ActionId::Undo.menu_id();
pub const REDO: usize = ActionId::Redo.menu_id();
pub const DELETE: usize = ActionId::Delete.menu_id();
pub const RIPPLE_DELETE: usize = ActionId::RippleDelete.menu_id();
pub const THEME_DARK: usize = ActionId::ThemeDark.menu_id();
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();
}
/// Modal-dialog control ids (see [`ModalEvent::control`]).
mod modal_ids {
pub const PREFERENCES: usize = 3;
pub const EXPORT: usize = 4;
pub const EXPORT_PROGRESS: usize = 5;
pub const MANAGER: usize = 6;
pub const MANAGER_RENAME: usize = 7;
pub const MANAGER_DELETE: usize = 8;
pub const PROXY: usize = 9;
}
/// What a picked platform-dialog path should do.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FileAction {
ImportFootage,
Open,
/// Export the current project to a file (the 导出工程文件… action's
/// target; `.ove` / `.otio` / `.fcpxml`, dispatched by extension).
ExportProjectFile,
/// Import a project file into the library (the manager's 导入).
ImportProject,
/// Export the selected library project (the manager's 导出; the row's
/// uuid is stashed in [`OakApp::pending_export`]).
ExportProject,
}
/// The modal currently layered on top of the shell, if any.
enum ModalState<E: AppEngine> {
None,
Preferences {
modal: Entity<Modal>,
content: Entity<PreferencesContent>,
},
Export {
modal: Entity<Modal>,
content: Entity<ExportDialogContent>,
},
Progress {
modal: Entity<Modal>,
content: Entity<ProgressContent>,
},
/// The project manager (M13 D4).
Manager {
modal: Entity<Modal>,
content: Entity<crate::manager::ProjectManager<E>>,
},
/// The manager's rename prompt.
ManagerRename {
modal: Entity<Modal>,
content: Entity<crate::manager::NamePrompt>,
uuid: String,
},
/// The manager's delete confirmation.
ManagerDelete { modal: Entity<Modal>, uuid: String },
/// The proxy settings dialog (Tools > Proxy Settings).
Proxy {
modal: Entity<Modal>,
content: Entity<crate::dialogs::ProxyDialogContent<E>>,
},
}
/// A running export: the session the tick loop drains for progress.
struct ExportRun {
session: ExportSession,
}
impl<E: AppEngine> ModalState<E> {
/// The modal entity currently shown, if any.
fn modal_entity(&self) -> Option<Entity<Modal>> {
match self {
ModalState::None => None,
ModalState::Preferences { modal, .. }
| ModalState::Export { modal, .. }
| ModalState::Progress { modal, .. }
| ModalState::Manager { modal, .. }
| ModalState::ManagerRename { modal, .. }
| ModalState::ManagerDelete { modal, .. }
| ModalState::Proxy { modal, .. } => Some(modal.clone()),
}
}
}
/// The panel registry: string keys for layout persistence, and the ability
/// to rebuild any panel from its key.
struct AppPanelRegistry<E: AppEngine> {
engine: Entity<E>,
source_clock: Entity<E::Clock>,
program_clock: Entity<E::Clock>,
}
impl<E: AppEngine> PanelRegistry for AppPanelRegistry<E> {
fn panel_key(&self, id: gpui::dock::PanelId) -> Option<String> {
Some(
match id {
PROJECT => "project",
SOURCE_VIEWER => "source-viewer",
PROGRAM_VIEWER => "program-viewer",
NODE_EDITOR => "node-editor",
INSPECTOR => "inspector",
HISTORY => "history",
TIMELINE => "timeline",
EFFECT_LIBRARY => "effect-library",
MULTICAM => "multicam",
_ => return None,
}
.to_string(),
)
}
fn build_panel(&self, key: &str, window: &mut Window, cx: &mut App) -> Option<PanelHandle> {
match key {
"project" => Some(PanelHandle::new(
cx.new(|cx| ProjectExplorerPanel::new(self.engine.clone(), window, cx)),
cx,
)),
"source-viewer" => Some(PanelHandle::new(
cx.new(|cx| {
SourceViewerPanel::new(
self.engine.clone(),
self.source_clock.clone(),
window,
cx,
)
}),
cx,
)),
"program-viewer" => Some(PanelHandle::new(
cx.new(|cx| {
let meter = cx.new(|cx| {
AudioLevelMeter::new(30, self.engine.clone(), window, cx)
.with_orientation(MeterOrientation::Vertical)
});
ProgramViewerPanel::new(
self.engine.clone(),
self.program_clock.clone(),
meter,
window,
cx,
)
}),
cx,
)),
"node-editor" => Some(PanelHandle::new(
cx.new(|cx| NodeEditorPanel::new(self.engine.clone(), window, cx)),
cx,
)),
"inspector" => Some(PanelHandle::new(
cx.new(|cx| InspectorPanel::new(self.engine.clone(), window, cx)),
cx,
)),
"history" => Some(PanelHandle::new(
cx.new(|cx| HistoryPanel::new(self.engine.clone(), window, cx)),
cx,
)),
"effect-library" => Some(PanelHandle::new(
cx.new(|cx| EffectLibraryPanel::new(self.engine.clone(), window, cx)),
cx,
)),
"timeline" => Some(PanelHandle::new(
cx.new(|cx| {
let timeline =
cx.new(|cx| TimelineView::new(self.engine.clone(), window, cx).zoom(2.0));
TimelinePanel::new(self.engine.clone(), timeline, window, cx)
}),
cx,
)),
"multicam" => Some(PanelHandle::new(
cx.new(|cx| {
MulticamPanel::new(self.engine.clone(), self.program_clock.clone(), window, cx)
}),
cx,
)),
_ => None,
}
}
}
/// The application root view.
pub struct OakApp<E: AppEngine> {
engine: Entity<E>,
program_clock: Entity<E::Clock>,
timeline: Entity<TimelineView<E>>,
meter: Entity<AudioLevelMeter<E>>,
menu_bar: Entity<MenuBar>,
dock: Entity<DockArea>,
status_bar: Entity<StatusBar<E>>,
/// Whether the dark theme is active (toggles via 视图 → 主题).
dark: bool,
/// The modal currently shown on top of the shell, if any.
modal: ModalState<E>,
/// The shell's own focus handle: the root element tracks it so the
/// action dispatch layer (the root's `on_action` listeners) sits on the
/// dispatch path even before any panel takes focus.
shell_focus: gpui::FocusHandle,
/// The running export session, if any.
export: Option<ExportRun>,
/// The library row pending an export save dialog (manager 导出).
pending_export: Option<String>,
/// The panel that most recently took focus (the target of
/// [`Route::FocusedPanel`](crate::actions::Route::FocusedPanel)
/// commands; `None` before any panel is focused).
focused_panel: Option<gpui::dock::PanelId>,
/// The eight dock panels, kept so focused-panel commands can be routed
/// to the right entity (the dock only hands back type-erased handles).
panels: ShellPanels<E>,
/// The active timeline tool (the Tools menu's exclusive group; the
/// timeline toolbar keeps its own visual selection for now).
active_tool: Tool,
/// 回放 → 循环播放 is on (the C++ `Loop` config flag; playback looping
/// itself is a transport gap, so this only drives the checkmark).
loop_playback: bool,
/// 视图 → Toggle Show All is on (placeholder state for the checkmark).
show_all: bool,
/// 视图 → Full Screen is on (placeholder state for the checkmark).
full_screen: bool,
}
/// The dock panels the shell builds up front, kept for focused-panel command
/// routing (the dock area only exposes type-erased handles).
struct ShellPanels<E: AppEngine> {
project: Entity<ProjectExplorerPanel<E>>,
source_viewer: Entity<SourceViewerPanel<E>>,
program_viewer: Entity<ProgramViewerPanel<E>>,
node_editor: Entity<NodeEditorPanel<E>>,
inspector: Entity<InspectorPanel<E>>,
history: Entity<HistoryPanel<E>>,
timeline: Entity<TimelinePanel<E>>,
effect_library: Entity<EffectLibraryPanel<E>>,
multicam: Entity<MulticamPanel<E>>,
}
impl<E: AppEngine> OakApp<E> {
/// Builds the whole shell. `initial_path` (a CLI argument) is opened
/// after the layout is up.
pub fn new(window: &mut Window, initial_path: Option<PathBuf>, cx: &mut Context<Self>) -> Self {
// The persisted theme (config `Theme`) wins; the app defaults to dark.
let dark = crate::oakui::real::theme_is_dark();
if dark {
apply_theme(cx, &OakTheme::olive_dark());
} else {
apply_theme(cx, &OakTheme::olive_light());
}
crate::oakui::icons::init(cx);
// --- engine and shared state ---------------------------------------
let engine = cx.new(|cx| E::create(cx));
let source_clock = engine.read(cx).source_clock().clone();
let program_clock = engine.read(cx).program_clock().clone();
let timeline = cx.new(|cx| TimelineView::new(engine.clone(), window, cx).zoom(2.0));
// M12 P4: install the waveform decorator when the engine provides
// a waveform cache.
if let Some(cache) = engine.read(cx).waveform_cache() {
let decorator: std::sync::Arc<std::sync::RwLock<dyn gpui::timeline::clip::ClipDecorator>> =
std::sync::Arc::new(std::sync::RwLock::new(
crate::oakui::waveform::OakClipDecorator { cache },
));
timeline.update(cx, |view, _| view.set_clip_decorator(decorator));
}
let meter = cx.new(|cx| {
AudioLevelMeter::new(3, engine.clone(), window, cx)
.with_orientation(MeterOrientation::Vertical)
});
// --- menu bar ------------------------------------------------------
// The menus are generated from the action registry; the dynamic
// checkmarks (theme, tool, snapping, loop, …) come from this state.
let menu_bar = cx.new(|cx| {
MenuBar::new(1, make_menus(MenuState::new(dark)), window, cx)
});
cx.subscribe(
&menu_bar,
|this, _menu: Entity<MenuBar>, event: &MenuBarEvent, cx| {
if let MenuBarEvent::Triggered { item, .. } = event {
this.on_menu(*item, cx);
}
},
)
.detach();
// --- keyboard map ---------------------------------------------------
// Register every registry default key as a global binding (context
// `None`): the keystrokes dispatch the gpui actions, which bubble to
// the shell's `on_action` listeners — the same path the menu clicks
// take through `on_menu`.
cx.bind_keys(crate::actions::key_bindings());
// --- dock ----------------------------------------------------------
let dock = cx.new(|cx| {
DockArea::new(cx).with_registry(Arc::new(AppPanelRegistry {
engine: engine.clone(),
source_clock: source_clock.clone(),
program_clock: program_clock.clone(),
}))
});
let project = cx.new(|cx| ProjectExplorerPanel::new(engine.clone(), window, cx));
let effect_library = cx.new(|cx| EffectLibraryPanel::new(engine.clone(), window, cx));
let source_viewer =
cx.new(|cx| SourceViewerPanel::new(engine.clone(), source_clock.clone(), window, cx));
let program_viewer = cx.new(|cx| {
ProgramViewerPanel::new(
engine.clone(),
program_clock.clone(),
meter.clone(),
window,
cx,
)
});
let node_editor = cx.new(|cx| NodeEditorPanel::new(engine.clone(), window, cx));
let inspector = cx.new(|cx| InspectorPanel::new(engine.clone(), window, cx));
let history = cx.new(|cx| HistoryPanel::new(engine.clone(), window, cx));
let timeline_panel =
cx.new(|cx| TimelinePanel::new(engine.clone(), timeline.clone(), window, cx));
let multicam_panel =
cx.new(|cx| MulticamPanel::new(engine.clone(), program_clock.clone(), window, cx));
// Keep the panel entities for focused-panel command routing (the dock
// only hands back type-erased handles).
let panels = ShellPanels {
project: project.clone(),
source_viewer: source_viewer.clone(),
program_viewer: program_viewer.clone(),
node_editor: node_editor.clone(),
inspector: inspector.clone(),
history: history.clone(),
timeline: timeline_panel.clone(),
effect_library: effect_library.clone(),
multicam: multicam_panel.clone(),
};
// Wire each panel's right-click menu: registry-backed items come
// back through `ContextMenuTriggered` and are dispatched exactly
// like menu-bar clicks. A right-click does not emit
// `PanelEvent::Focused`, so the subscription points `focused_panel`
// at the panel before dispatching (the C++
// `PanelManager::currently_focused` target is the clicked panel).
Self::wire_panel_context_menu(cx, &panels.project, PROJECT);
Self::wire_panel_context_menu(cx, &panels.source_viewer, SOURCE_VIEWER);
Self::wire_panel_context_menu(cx, &panels.program_viewer, PROGRAM_VIEWER);
Self::wire_panel_context_menu(cx, &panels.node_editor, NODE_EDITOR);
Self::wire_panel_context_menu(cx, &panels.inspector, INSPECTOR);
Self::wire_panel_context_menu(cx, &panels.timeline, TIMELINE);
// Arrange the default workspace: the design's 素材查看器 | 序列查看器 |
// 检查器 row (project bin docked on the left), node editor + history
// as tabs, timeline full width at the bottom.
dock.update(cx, |dock, cx| {
dock.add_panel(PanelHandle::new(project, cx), None, cx);
// The effect library tabs behind the project bin, per the
// design's left-top tab group (项目 | 效果库).
dock.add_panel(
PanelHandle::new(effect_library, cx),
Some(DropTarget {
panel: Some(PROJECT),
zone: DropZone::Center,
}),
cx,
);
dock.add_panel(
PanelHandle::new(source_viewer, cx),
Some(DropTarget {
panel: Some(PROJECT),
zone: DropZone::Right,
}),
cx,
);
dock.add_panel(
PanelHandle::new(program_viewer, cx),
Some(DropTarget {
panel: Some(SOURCE_VIEWER),
zone: DropZone::Right,
}),
cx,
);
dock.add_panel(
PanelHandle::new(node_editor, cx),
Some(DropTarget {
panel: Some(PROGRAM_VIEWER),
zone: DropZone::Center,
}),
cx,
);
dock.add_panel(
PanelHandle::new(inspector, cx),
Some(DropTarget {
panel: Some(PROGRAM_VIEWER),
zone: DropZone::Right,
}),
cx,
);
dock.add_panel(
PanelHandle::new(history, cx),
Some(DropTarget {
panel: Some(INSPECTOR),
zone: DropZone::Center,
}),
cx,
);
dock.add_panel(
PanelHandle::new(timeline_panel, cx),
Some(DropTarget {
panel: None,
zone: DropZone::Bottom,
}),
cx,
);
// The multicam panel tabs behind the program viewer (the C++
// default is hidden; the 窗口 menu's Focus Multicam brings it
// forward). The program viewer stays the group's active tab.
dock.add_panel(
PanelHandle::new(multicam_panel, cx),
Some(DropTarget {
panel: Some(PROGRAM_VIEWER),
zone: DropZone::Center,
}),
cx,
);
});
// Tune the default split ratios: viewers 60% / timeline 40%, project
// bin 17% of the row. The timeline share leaves room for all four
// tracks (V2/V1 video + A1/A2 audio) plus the ruler and toolbar at
// 1600×900; the viewers keep the remaining ~60%. The program viewer
// (with its audio level strip) is the active tab of its group, so the
// shell opens on the design's visible 素材查看器 | 序列查看器 row rather
// than on the node editor.
let mut layout: DockLayout = dock.read(cx).layout().clone();
layout.resize_split(&NodePath(vec![]), 0.60);
layout.resize_split(&NodePath(vec![0]), 0.17);
// The program viewer's transport row (six transport buttons, the
// timecode, the 安全框/缩放 toggles) plus its 26px meter strip needs
// ~430px at 1600×900 — more than an equal share of the row gives it,
// and the design makes the program monitor the prominent viewer. Tilt
// the source/program and program/inspector boundaries accordingly so
// the transport's trailing toggles are not clipped.
layout.resize_split_child(&NodePath(vec![0]), 1, 0.52);
layout.resize_split_child(&NodePath(vec![0]), 2, 0.62);
if let Some(path) = layout.find_panel(PROGRAM_VIEWER) {
layout.set_tabs_active(&path, PROGRAM_VIEWER);
}
// The project bin is the active tab of its group (the effect library
// sits behind it).
if let Some(path) = layout.find_panel(PROJECT) {
layout.set_tabs_active(&path, PROJECT);
}
dock.update(cx, |dock, cx| dock.set_layout(layout, cx));
// --- status bar ----------------------------------------------------
let status_bar = cx.new(|cx| StatusBar::new(engine.clone(), program_clock.clone(), cx));
// Repaint the shell whenever the engine notifies.
cx.observe(&engine, |this, _engine, cx| {
cx.notify();
let _ = this;
})
.detach();
// 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);
}
})
.detach();
// --- timeline events -----------------------------------------------
// Every timeline widget request (playhead seek, trim, move, track
// height) is applied by the engine through its backend's edit
// commands; the playhead is routed to the program monitor.
// `SelectionChanged` carries no payload — the selection is read from
// the view and forwarded to the engine so the inspector's effect
// stack can target the selected clip.
cx.subscribe(&timeline, |this, timeline, event: &TimelineEvent, cx| {
if matches!(event, TimelineEvent::SelectionChanged) {
let clips: Vec<ClipId> = timeline.read(cx).selection().iter().copied().collect();
this.engine
.update(cx, |engine, cx| engine.set_selected_clips(clips, cx));
}
this.engine
.update(cx, |engine, cx| engine.apply_timeline_event(event, cx));
})
.detach();
// --- tick loop -----------------------------------------------------
// Drives playback clocks, playhead sync, the audio meter and the
// export progress at ~60Hz.
let this = cx.weak_entity();
window
.spawn(cx, async move |cx: &mut AsyncWindowContext| loop {
cx.background_executor()
.timer(Duration::from_millis(16))
.await;
let _ = cx.update(|_window, app| {
if let Some(this) = this.upgrade() {
this.update(app, |this, cx| this.tick(cx));
}
});
})
.detach();
let shell_focus = cx.focus_handle();
// The shell starts focused so the action dispatch layer works before
// any panel grabs focus.
window.focus(&shell_focus, cx);
let shell = Self {
engine,
program_clock,
timeline,
meter,
menu_bar,
dock,
status_bar,
dark,
modal: ModalState::None,
shell_focus,
export: None,
pending_export: None,
focused_panel: None,
panels,
active_tool: Tool::Pointer,
loop_playback: false,
show_all: false,
full_screen: false,
};
// Open the CLI-provided project once the shell is up.
if let Some(path) = initial_path {
shell.engine.update(cx, |engine, cx| {
if let Err(err) = engine.open_project_path(path.clone(), cx) {
println!("[app] failed to open {}: {err}", path.display());
}
});
}
shell
}
/// One animation-frame tick: advance the engine, sync the timeline
/// playhead to the program clock, refresh the audio meter and drain the
/// export progress events.
fn tick(&mut self, cx: &mut Context<Self>) {
self.engine.update(cx, |engine, cx| engine.tick(cx));
let frame = self.program_clock.read(cx).current_frame();
self.timeline
.update(cx, |timeline, cx| timeline.seek(frame, cx));
// Mirror the engine's work area into the ruler's view state (M12 P4).
// Read every tick so undo/redo and the ruler-drag commit land on the
// band promptly; the read is a cheap engine getter.
let work_area = self.engine.read(cx).workarea();
self.timeline
.update(cx, |timeline, _| timeline.state.work_area = work_area.map(|(s, e)| FrameRange::new(s, e)));
self.meter.update(cx, |meter, cx| meter.update(cx));
self.poll_export(cx);
cx.notify();
}
/// Routes a menu click: resolves the item id to its registry action and
/// dispatches it through the same path the keyboard shortcuts use, so a
/// menu click and a key press can never diverge.
fn on_menu(&mut self, item: usize, cx: &mut Context<Self>) {
let Some(entry) = crate::actions::entry_for_menu_id(item) else {
println!("[menu] unknown item {item}");
return;
};
self.dispatch_action_id(entry.action, cx);
}
/// Subscribes the shell to a panel's right-click menu: a triggered
/// registry item is dispatched like a menu-bar click, with the panel set
/// as the focused panel first (a right-click does not emit
/// `PanelEvent::Focused`).
fn wire_panel_context_menu<P>(cx: &mut Context<Self>, panel: &Entity<P>, id: PanelId)
where
P: gpui::EventEmitter<crate::menus::context::ContextMenuTriggered>,
{
cx.subscribe(
panel,
move |this, _panel, event: &crate::menus::context::ContextMenuTriggered, cx| {
this.focused_panel = Some(id);
this.on_menu(event.item, cx);
},
)
.detach();
}
/// The central action dispatch (menu clicks and keyboard shortcuts both
/// end up here). [`Route::FocusedPanel`](crate::actions::Route::FocusedPanel)
/// actions go to the focused panel first; whatever the panel does not
/// implement falls through to the shell's global handler.
fn dispatch_action_id(&mut self, action: ActionId, cx: &mut Context<Self>) {
let entry = action.entry();
if entry.route == crate::actions::Route::FocusedPanel
&& self.dispatch_to_focused_panel(action, cx)
{
return;
}
self.handle_global_action(action, cx);
}
/// Hands `action` to the focused panel's
/// [`PanelCommandHandler`](panel_commands::PanelCommandHandler); whether
/// it was handled. No focused panel (or the panel declines) means the
/// shell's global handler runs instead.
fn dispatch_to_focused_panel(&mut self, action: ActionId, cx: &mut Context<Self>) -> bool {
let Some(id) = self.focused_panel else {
return false;
};
match id {
PROJECT => self
.panels
.project
.update(cx, |panel, cx| panel_commands::dispatch_to(panel, action, cx)),
SOURCE_VIEWER => self.panels.source_viewer.update(cx, |panel, cx| {
panel_commands::dispatch_to(panel, action, cx)
}),
PROGRAM_VIEWER => self.panels.program_viewer.update(cx, |panel, cx| {
panel_commands::dispatch_to(panel, action, cx)
}),
NODE_EDITOR => self.panels.node_editor.update(cx, |panel, cx| {
panel_commands::dispatch_to(panel, action, cx)
}),
INSPECTOR => self
.panels
.inspector
.update(cx, |panel, cx| panel_commands::dispatch_to(panel, action, cx)),
HISTORY => self
.panels
.history
.update(cx, |panel, cx| panel_commands::dispatch_to(panel, action, cx)),
TIMELINE => self
.panels
.timeline
.update(cx, |panel, cx| panel_commands::dispatch_to(panel, action, cx)),
EFFECT_LIBRARY => self.panels.effect_library.update(cx, |panel, cx| {
panel_commands::dispatch_to(panel, action, cx)
}),
MULTICAM => self
.panels
.multicam
.update(cx, |panel, cx| panel_commands::dispatch_to(panel, action, cx)),
_ => false,
}
}
/// The shell's global action handler: file dialogs, undo, view
/// preferences, tools, transport fallbacks — and the placeholder
/// `println!` for the actions not wired yet.
fn handle_global_action(&mut self, action: ActionId, cx: &mut Context<Self>) {
use crate::actions::ActionId as A;
match action {
// --- File ------------------------------------------------------
A::NewProject => self.new_project(cx),
A::OpenProject => self.open_file_dialog(FileAction::Open, cx),
A::OpenFromLibrary | A::ProjectManager => self.show_project_manager(cx),
A::Import => self.open_file_dialog(FileAction::ImportFootage, cx),
A::SaveProject | A::SaveProjectAs => {
self.open_file_dialog(FileAction::ExportProjectFile, cx)
}
A::CloseProject => self
.engine
.update(cx, |engine, cx| engine.close_project(cx)),
A::Export => self.open_export_dialog(cx),
A::Exit => cx.quit(),
// --- Edit ------------------------------------------------------
A::Undo => self.engine.update(cx, |engine, cx| engine.undo(cx)),
A::Redo => self.engine.update(cx, |engine, cx| engine.redo(cx)),
A::Delete => self.delete_timeline_selection(false, cx),
A::RippleDelete => self.delete_timeline_selection(true, cx),
A::SelectAll => self.select_all_clips(cx),
A::DeselectAll => self.deselect_all_clips(cx),
A::SplitAtPlayhead => self
.engine
.update(cx, |engine, cx| engine.split_at_playhead(cx)),
A::SetInPoint => self.set_point_at_playhead(true, cx),
A::SetOutPoint => self.set_point_at_playhead(false, cx),
A::ClearInOut | A::ClearWorkArea => self
.engine
.update(cx, |engine, cx| engine.clear_workarea(cx)),
A::Marker => self
.engine
.update(cx, |engine, cx| engine.add_marker_at_playhead(cx)),
// --- View ------------------------------------------------------
A::ThemeDark => {
crate::oakui::real::set_theme_dark(true);
self.apply_dark(true, cx);
}
A::ThemeLight => {
crate::oakui::real::set_theme_dark(false);
self.apply_dark(false, cx);
}
A::ZoomIn => self.zoom_timeline(1.25, cx),
A::ZoomOut => self.zoom_timeline(0.8, cx),
A::IncreaseTrackHeight => self.nudge_track_height(8.0, cx),
A::DecreaseTrackHeight => self.nudge_track_height(-8.0, cx),
A::ToggleShowAll => {
self.show_all = !self.show_all;
println!(
"[view] toggle show all: {} (placeholder)",
self.show_all
);
self.rebuild_menu_bar(cx);
}
A::FullScreen => {
self.full_screen = !self.full_screen;
println!("[view] full screen: {} (placeholder)", self.full_screen);
self.rebuild_menu_bar(cx);
}
A::LangZh => self.switch_language(crate::i18n::Language::ZhCN, cx),
A::LangEn => self.switch_language(crate::i18n::Language::EnUs, cx),
A::Preferences => self.open_preferences(cx),
// --- Playback (the program monitor) ----------------------------
A::PlayPause => {
let playing = self.program_clock.read(cx).is_playing();
let monitor = Monitor::Program;
self.engine.update(cx, |engine, cx| {
if playing {
engine.pause(monitor, cx);
} else {
engine.play(monitor, cx);
}
});
}
A::PrevFrame => {
let monitor = Monitor::Program;
self.engine
.update(cx, |engine, cx| engine.step(monitor, -1, cx));
}
A::NextFrame => {
let monitor = Monitor::Program;
self.engine
.update(cx, |engine, cx| engine.step(monitor, 1, cx));
}
A::ShuttleLeft => {
// J steps back — true reverse playback is an engine transport
// gap (the old shortcut table did the same).
let monitor = Monitor::Program;
self.engine
.update(cx, |engine, cx| engine.step(monitor, -1, cx));
}
A::ShuttleStop => {
let monitor = Monitor::Program;
self.engine
.update(cx, |engine, cx| engine.pause(monitor, cx));
}
A::ShuttleRight => {
let monitor = Monitor::Program;
self.engine
.update(cx, |engine, cx| engine.play(monitor, cx));
}
A::GoToStart => {
let monitor = Monitor::Program;
self.engine.update(cx, |engine, cx| {
engine.request_frame(monitor, Frame::ZERO, cx)
});
}
A::GoToEnd => {
let monitor = Monitor::Program;
let length = self.engine.read(cx).sequence_length();
self.engine.update(cx, |engine, cx| {
engine.request_frame(monitor, length, cx)
});
}
A::GoToIn => {
if let Some((start, _)) = self.engine.read(cx).workarea() {
let monitor = Monitor::Program;
self.engine.update(cx, |engine, cx| {
engine.request_frame(monitor, start, cx)
});
}
}
A::GoToOut => {
if let Some((_, end)) = self.engine.read(cx).workarea() {
let monitor = Monitor::Program;
self.engine.update(cx, |engine, cx| {
engine.request_frame(monitor, end, cx)
});
}
}
A::PlayInToOut => {
// Seek to the work area's start, then play (out-point
// stopping is a transport gap).
let monitor = Monitor::Program;
if let Some((start, _)) = self.engine.read(cx).workarea() {
self.engine.update(cx, |engine, cx| {
engine.request_frame(monitor, start, cx)
});
}
self.engine.update(cx, |engine, cx| engine.play(monitor, cx));
}
A::Loop => {
self.loop_playback = !self.loop_playback;
println!("[playback] loop: {} (placeholder)", self.loop_playback);
self.rebuild_menu_bar(cx);
}
// --- Sequence --------------------------------------------------
A::AddVideoTrack => {
let kind = gpui::timeline::TrackKind::Video;
self.engine
.update(cx, |engine, cx| engine.add_track(kind, cx));
}
A::AddAudioTrack => {
let kind = gpui::timeline::TrackKind::Audio;
self.engine
.update(cx, |engine, cx| engine.add_track(kind, cx));
}
A::RemoveTrack => self.remove_selected_track(cx),
A::RemoveMarker => self
.engine
.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),
// --- Tools -----------------------------------------------------
A::Snapping => {
let enabled = !self.timeline.read(cx).state.snap_enabled;
self.timeline.update(cx, |timeline, cx| {
timeline.state.snap_enabled = enabled;
cx.notify();
});
println!("[tools] snapping: {enabled}");
self.rebuild_menu_bar(cx);
}
tool_action if Tool::from_action(tool_action).is_some() => {
let tool = Tool::from_action(tool_action).unwrap();
self.active_tool = tool;
println!("[tools] selected: {tool:?} (placeholder behavior)");
self.rebuild_menu_bar(cx);
}
// --- Proxy (Tools) ---------------------------------------------
A::UseProxyMedia => {
let enabled = !self.engine.read(cx).use_proxy_media();
self.engine.update(cx, |engine, cx| {
engine.set_use_proxy_media(enabled, cx)
});
self.rebuild_menu_bar(cx);
}
A::ProxySettings => self.open_proxy_dialog(cx),
// The multicam source-switch hotkeys are scoped to the Multicam
// panel (the focused-panel route handles them there); a fall-through
// from any other focused panel is a silent no-op.
A::MulticamSwitch1
| A::MulticamSwitch2
| A::MulticamSwitch3
| A::MulticamSwitch4
| A::MulticamSwitch5
| A::MulticamSwitch6
| A::MulticamSwitch7
| A::MulticamSwitch8
| A::MulticamSwitch9
| A::MulticamSwitchNoSplit1
| A::MulticamSwitchNoSplit2
| A::MulticamSwitchNoSplit3
| A::MulticamSwitchNoSplit4
| A::MulticamSwitchNoSplit5
| A::MulticamSwitchNoSplit6
| A::MulticamSwitchNoSplit7
| A::MulticamSwitchNoSplit8
| A::MulticamSwitchNoSplit9 => {}
// --- everything else is a placeholder --------------------------
other => println!(
"[action] {} not wired yet (placeholder)",
other.entry().cpp_id
),
}
}
/// 新建项目: creates a blank project in the library and opens it (the
/// write-through persists it from the first edit). Falls back to the
/// engine's plain new-project path when the library is unavailable.
fn new_project(&mut self, cx: &mut Context<Self>) {
let name = crate::i18n::tr("manager.new.default_name").to_string();
let result = self
.engine
.update(cx, |engine, cx| engine.library_create_project(&name, cx));
if let Err(err) = result {
println!("[file] library create failed ({err}); plain new project");
self.engine.update(cx, |engine, cx| engine.new_project(cx));
}
}
/// Deletes the timeline's selected clips (ripple or gap) through the
/// engine's edit commands.
fn delete_timeline_selection(&mut self, ripple: bool, cx: &mut Context<Self>) {
let ids: Vec<gpui::timeline::ClipId> =
self.timeline.read(cx).selection().iter().copied().collect();
if ids.is_empty() {
println!("[timeline] delete: nothing selected");
return;
}
for id in ids {
self.engine
.update(cx, |engine, cx| engine.delete_clip(id, ripple, cx));
}
}
/// 编辑 → 全选: selects every timeline clip and forwards the selection
/// to the engine (the effect stack's target), mirroring what the
/// timeline's own `SelectionChanged` subscription does.
fn select_all_clips(&mut self, cx: &mut Context<Self>) {
let ids: Vec<ClipId> = {
let engine = self.engine.read(cx);
let mut ids = Vec::new();
for index in 0..engine.track_count() {
if let Some(track) = engine.track(index) {
ids.extend(track.clips().iter().map(|clip| clip.id()));
}
}
ids
};
self.timeline.update(cx, |view, cx| {
view.state.select_range(ids.iter().copied());
cx.notify();
});
self.engine
.update(cx, |engine, cx| engine.set_selected_clips(ids, cx));
}
/// 编辑 → 取消全选: clears the timeline selection and tells the engine
/// (the effect stack's target follows).
fn deselect_all_clips(&mut self, cx: &mut Context<Self>) {
self.timeline.update(cx, |view, cx| {
view.state.select_range(std::iter::empty::<ClipId>());
cx.notify();
});
self.engine
.update(cx, |engine, cx| engine.set_selected_clips(Vec::new(), cx));
}
/// 视图 → 放大/缩小: scales the timeline zoom around its left edge
/// (`TimelineState::set_zoom` clamps to the widget's zoom range).
fn zoom_timeline(&mut self, factor: f32, cx: &mut Context<Self>) {
self.timeline.update(cx, |view, cx| {
let zoom = view.state.zoom * factor;
view.state.set_zoom(zoom, px(0.));
cx.notify();
});
}
/// 视图 → 轨道高度 ±: steps every track's height by `delta` pixels,
/// clamped to the track-height slider's range (24160px).
fn nudge_track_height(&mut self, delta: f32, cx: &mut Context<Self>) {
let current = self
.engine
.read(cx)
.track(0)
.map(|track| f32::from(track.height()))
.unwrap_or(64.0);
let next = (current + delta).clamp(24.0, 160.0);
self.engine
.update(cx, |engine, cx| engine.set_track_height(px(next), cx));
}
/// 回放 → 设置入点/出点: moves the work area's start (`in_point`) or end
/// to the program playhead as ONE undoable entry (the same commit the
/// ruler drag and 序列 → 设置工作区 use). Without an existing work area
/// the far edge falls back to the sequence bounds.
fn set_point_at_playhead(&mut self, in_point: bool, cx: &mut Context<Self>) {
let playhead = self.program_clock.read(cx).current_frame();
let seq_len = self
.engine
.read(cx)
.current_sequence()
.map(|s| s.length)
.unwrap_or(Frame(playhead.0 + 1));
let (old_start, old_end) = self
.engine
.read(cx)
.workarea()
.unwrap_or((Frame::ZERO, seq_len));
let (start, end) = if in_point {
(playhead, old_end.max(Frame(playhead.0 + 1)))
} else {
(old_start.min(Frame((playhead.0 - 1).max(0))), playhead)
};
if end.0 <= start.0 {
println!("[playback] set in/out point: empty range, ignored");
return;
}
self.engine.update(cx, |engine, cx| {
engine.commit_workarea(old_start, old_end, start, end, cx);
});
}
/// Applies the dark/light theme and rebuilds the menu bar (the theme
/// checkmark moves). The caller persists the choice through
/// [`crate::oakui::real::set_theme_dark`].
fn apply_dark(&mut self, dark: bool, cx: &mut Context<Self>) {
self.dark = dark;
if dark {
apply_theme(cx, &OakTheme::olive_dark());
} else {
apply_theme(cx, &OakTheme::olive_light());
}
self.rebuild_menu_bar(cx);
cx.notify();
}
/// The shell's keyboard entry point: the global key bindings dispatch
/// gpui actions, which bubble up to the root's action listeners and land
/// here — the same [`Self::dispatch_action_id`] path the menu clicks
/// take. While a modal dialog is open the shell stays keyboard-quiet, so
/// the dialogs' text fields never trigger editing actions.
fn on_action_dispatched(&mut self, action: ActionId, cx: &mut Context<Self>) {
if !matches!(self.modal, ModalState::None) {
return;
}
self.dispatch_action_id(action, cx);
}
/// Removes the first track selected in the timeline header.
fn remove_selected_track(&mut self, cx: &mut Context<Self>) {
let Some(&index) = self.timeline.read(cx).selected_tracks().iter().next() else {
println!("[timeline] remove track: nothing selected");
return;
};
self.engine
.update(cx, |engine, cx| engine.remove_track(index, cx));
}
/// 序列 → 设置工作区: sets the work area to the bounding range of the
/// selected clips, or one frame at the program playhead when nothing is
/// selected. Committed as ONE undoable entry whose old side is the
/// engine's current work area (mirrors the ruler drag's commit).
fn set_workarea_from_selection(&mut self, cx: &mut Context<Self>) {
let (old_start, old_end) = self
.engine
.read(cx)
.workarea()
.unwrap_or((Frame::ZERO, Frame::ZERO));
let (start, end) = self.selection_workarea_range(cx);
self.engine.update(cx, |engine, cx| {
engine.commit_workarea(old_start, old_end, start, end, cx);
});
}
/// The bounding range of the timeline's selected clips; `[playhead,
/// playhead + 1)` when nothing is selected (the menu's fallback).
fn selection_workarea_range(&self, cx: &App) -> (Frame, Frame) {
let ids: Vec<ClipId> = self.timeline.read(cx).selection().iter().copied().collect();
let engine = self.engine.read(cx);
let mut start: Option<i64> = None;
let mut end: i64 = 0;
for index in 0..engine.track_count() {
if let Some(track) = engine.track(index) {
for clip in track.clips() {
if ids.contains(&clip.id()) {
let range = clip.range();
start = Some(start.map_or(range.start.0, |s| s.min(range.start.0)));
end = end.max(range.end.0);
}
}
}
}
match start {
Some(s) if end > s => (Frame(s), Frame(end)),
_ => {
let playhead = self.program_clock.read(cx).current_frame();
(playhead, Frame(playhead.0 + 1))
}
}
}
/// 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() {
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));
});
}
}
/// Switches the UI language live: updates the [`i18n`] global, rebuilds
/// the menu bar (so the menu labels and the language checkmark move
/// immediately), and repaints the whole shell.
fn switch_language(&mut self, language: crate::i18n::Language, cx: &mut Context<Self>) {
crate::i18n::set_language(language);
self.rebuild_menu_bar(cx);
cx.notify();
}
/// Replaces the `MenuBar` entity with one built from the current language
/// and the dynamic checkmark state, re-subscribing to its trigger events.
fn rebuild_menu_bar(&mut self, cx: &mut Context<Self>) {
let windows = cx.windows();
let Some(handle) = windows.first() else {
return;
};
let state = MenuState {
dark: self.dark,
active_tool: self.active_tool,
snapping: self.timeline.read(cx).state.snap_enabled,
loop_playback: self.loop_playback,
show_all: self.show_all,
full_screen: self.full_screen,
use_proxy_media: self.engine.read(cx).use_proxy_media(),
};
let Ok(menu_bar) = cx.update_window(*handle, |_root, window, app| {
app.new(|cx| MenuBar::new(1, make_menus(state), window, cx))
}) else {
return;
};
self.menu_bar = menu_bar;
let menu_bar = self.menu_bar.clone();
cx.subscribe(
&menu_bar,
|this, _menu: Entity<MenuBar>, event: &MenuBarEvent, cx| {
if let MenuBarEvent::Triggered { item, .. } = event {
this.on_menu(*item, cx);
}
},
)
.detach();
}
// -----------------------------------------------------------------------
// Project manager (M13 D4)
// -----------------------------------------------------------------------
/// Opens the project manager (startup without a project argument, and
/// 文件 → 项目管理器 / 从库中打开…). Re-entrant: an already-open
/// manager just reloads its list.
pub fn show_project_manager(&mut self, cx: &mut Context<Self>) {
if let ModalState::Manager { content, .. } = &self.modal {
let content = content.clone();
content.update(cx, |manager, cx| manager.reload(cx));
return;
}
let engine = self.engine.clone();
self.spawn_modal(cx, move |window, app| {
let content = app.new(|cx| crate::manager::ProjectManager::new(engine, window, cx));
let modal = app.new(|cx| {
Modal::new(
modal_ids::MANAGER,
ModalOptions::new(crate::i18n::tr("manager.title"), px(880.0))
.with_button(DialogButton::cancel(crate::i18n::tr("dialog.close"))),
window,
cx,
)
.with_content(content.clone())
});
ModalState::Manager { modal, content }
});
// The content's requests (open / create / rename / ...) route here.
if let ModalState::Manager { content, .. } = &self.modal {
let content = content.clone();
cx.subscribe(
&content,
|this, _content, event: &crate::manager::ManagerEvent, cx| {
this.on_manager_event(event, cx);
},
)
.detach();
}
}
/// Routes a project-manager request through the engine. Open / Create
/// close the dialog on success; the mutating actions reload the list;
/// failures land in the dialog's status line.
fn on_manager_event(&mut self, event: &crate::manager::ManagerEvent, cx: &mut Context<Self>) {
use crate::manager::ManagerEvent as E;
match event {
E::Create => {
let name = crate::i18n::tr("manager.new.default_name").to_string();
let result = self
.engine
.update(cx, |engine, cx| engine.library_create_project(&name, cx));
match result {
Ok(()) => self.close_modal(cx),
Err(err) => self.manager_status(err, cx),
}
}
E::Open(uuid) => {
let uuid = uuid.clone();
let result = self
.engine
.update(cx, |engine, cx| engine.library_open_project(&uuid, cx));
match result {
Ok(()) => self.close_modal(cx),
Err(err) => self.manager_status(err, cx),
}
}
E::Rename(uuid) => self.open_manager_rename(uuid.clone(), cx),
E::Duplicate(uuid) => {
let result = self
.engine
.update(cx, |engine, _cx| engine.library_duplicate_project(uuid));
match result {
Ok(()) => self.reload_manager(cx),
Err(err) => self.manager_status(err, cx),
}
}
E::Delete(uuid) => self.open_manager_delete(uuid.clone(), cx),
E::Import => self.open_file_dialog(FileAction::ImportProject, cx),
E::Export(uuid) => self.open_manager_export(uuid.clone(), cx),
}
}
/// Reloads the open manager's list (after a library mutation).
fn reload_manager(&mut self, cx: &mut Context<Self>) {
if let ModalState::Manager { content, .. } = &self.modal {
let content = content.clone();
content.update(cx, |manager, cx| manager.reload(cx));
}
}
/// Shows an engine error in the open manager's status line (or logs it
/// when the manager is gone).
fn manager_status(&mut self, message: String, cx: &mut Context<Self>) {
if let ModalState::Manager { content, .. } = &self.modal {
let content = content.clone();
content.update(cx, |manager, cx| manager.set_status(Some(message), cx));
} else {
println!("[manager] {message}");
}
}
/// The selected row's display name in the open manager (used to seed
/// the rename prompt / the delete confirmation / the export filename).
fn manager_selected_name(&self, cx: &App) -> Option<String> {
match &self.modal {
ModalState::Manager { content, .. } => content.read(cx).selected_name(),
_ => None,
}
}
/// Swaps the manager for the rename prompt of row `uuid`.
fn open_manager_rename(&mut self, uuid: String, cx: &mut Context<Self>) {
let initial = self.manager_selected_name(cx).unwrap_or_default();
self.spawn_modal(cx, move |window, app| {
let content = app.new(|cx| crate::manager::NamePrompt::new(&initial, cx));
let modal = app.new(|cx| {
Modal::new(
modal_ids::MANAGER_RENAME,
ModalOptions::new(crate::i18n::tr("manager.rename.title"), px(380.0))
.with_button(DialogButton::primary(crate::i18n::tr(
"manager.rename.title",
)))
.with_button(DialogButton::cancel(crate::i18n::tr("dialog.cancel"))),
window,
cx,
)
.with_content(content.clone())
});
ModalState::ManagerRename { modal, content, uuid }
});
}
/// Swaps the manager for the delete confirmation of row `uuid`.
fn open_manager_delete(&mut self, uuid: String, cx: &mut Context<Self>) {
let name = self.manager_selected_name(cx).unwrap_or_default();
let text = crate::i18n::tr("manager.delete.confirm").replace("{name}", &name);
self.spawn_modal(cx, move |window, app| {
let content = app.new(|_cx| crate::manager::ConfirmContent::new(text));
let modal = app.new(|cx| {
Modal::new(
modal_ids::MANAGER_DELETE,
ModalOptions::new(crate::i18n::tr("manager.delete.title"), px(420.0))
.with_button(DialogButton::primary(crate::i18n::tr("manager.delete")))
.with_button(DialogButton::cancel(crate::i18n::tr("dialog.cancel"))),
window,
cx,
)
.with_content(content.clone())
});
ModalState::ManagerDelete { modal, uuid }
});
}
/// Confirms the rename prompt (button 0).
fn confirm_manager_rename(&mut self, cx: &mut Context<Self>) {
let ModalState::ManagerRename { content, uuid, .. } = &self.modal else {
return;
};
let name = content.read(cx).value(cx);
let uuid = uuid.clone();
if name.is_empty() {
self.back_to_manager(cx);
return;
}
let result = self
.engine
.update(cx, |engine, _cx| engine.library_rename_project(&uuid, &name));
match result {
Ok(()) => self.back_to_manager(cx),
Err(err) => {
self.back_to_manager(cx);
self.manager_status(err, cx);
}
}
}
/// Confirms the delete confirmation (button 0).
fn confirm_manager_delete(&mut self, cx: &mut Context<Self>) {
let ModalState::ManagerDelete { uuid, .. } = &self.modal else {
return;
};
let uuid = uuid.clone();
let result = self
.engine
.update(cx, |engine, _cx| engine.library_delete_project(&uuid));
match result {
Ok(()) => self.back_to_manager(cx),
Err(err) => {
self.back_to_manager(cx);
self.manager_status(err, cx);
}
}
}
/// Returns from a manager sub-dialog (rename / delete) to the manager.
fn back_to_manager(&mut self, cx: &mut Context<Self>) {
self.modal = ModalState::None;
self.show_project_manager(cx);
}
/// Opens the platform save dialog for exporting the library row `uuid`
/// (the suggested name is `<project>.ove`; the format follows the
/// extension the user picks).
fn open_manager_export(&mut self, uuid: String, cx: &mut Context<Self>) {
let name = self
.manager_selected_name(cx)
.filter(|n| !n.is_empty())
.unwrap_or_else(|| "project".to_string());
self.pending_export = Some(uuid);
let receiver =
cx.prompt_for_new_path(&PathBuf::from("."), Some(&format!("{name}.ove")));
cx.spawn(async move |this, cx| {
if let Ok(Ok(Some(path))) = receiver.await {
this.update(cx, |this, cx| {
this.on_file_paths(FileAction::ExportProject, vec![path], cx);
});
}
})
.detach();
}
// -----------------------------------------------------------------------
// Modal dialogs
// -----------------------------------------------------------------------
/// Closes the current modal.
pub fn close_modal(&mut self, cx: &mut Context<Self>) {
self.modal = ModalState::None;
cx.notify();
}
/// Builds a modal on the main window, subscribes it to
/// [`Self::on_modal`] and layers it onto the shell.
///
/// The modal is created inside `update_window` (modal widgets need a
/// `&mut Window`); the state swap and the subscription happen *after* the
/// window update returns, on this entity's own `Context` — swapping state
/// through a weak handle *inside* the window callback would re-enter this
/// entity while it is already being updated (the crash seen when opening
/// Preferences from a menu action).
///
/// The caller must NOT be inside a window update itself (e.g.
/// `WindowHandle::update`): the nested `update_window` below would fail
/// and the modal would silently not open. Drive the root entity instead
/// (menu actions and entity updates are fine).
fn spawn_modal(
&mut self,
cx: &mut Context<Self>,
build: impl FnOnce(&mut Window, &mut App) -> ModalState<E>,
) {
let windows = cx.windows();
let Some(handle) = windows.first() else {
return;
};
let Ok(state) = cx.update_window(*handle, |_root, window, app| build(window, app)) else {
return;
};
let modal = state
.modal_entity()
.expect("spawned modal always carries a Modal");
cx.subscribe(&modal, |this, _entity, event: &ModalEvent, cx| {
this.on_modal(event, cx);
})
.detach();
self.modal = state;
cx.notify();
}
/// Opens the platform file dialog for `action` and routes the picked
/// path(s) through the engine. Open / Import use the path picker (import
/// footage allows multiple files); the 导出工程文件… and the manager's
/// export ask for a new path. The picker resolves asynchronously, so the
/// chosen path is applied in a spawned task via [`Self::on_file_paths`].
fn open_file_dialog(&mut self, action: FileAction, cx: &mut Context<Self>) {
match action {
FileAction::Open | FileAction::ImportFootage | FileAction::ImportProject => {
let prompt = match action {
FileAction::Open => crate::i18n::tr("file.open.title"),
FileAction::ImportProject => crate::i18n::tr("manager.import.title"),
_ => crate::i18n::tr("file.import_footage.title"),
};
let receiver = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: action == FileAction::ImportFootage,
prompt: Some(prompt.into()),
});
cx.spawn(async move |this, cx| {
if let Ok(Ok(Some(paths))) = receiver.await {
if !paths.is_empty() {
this.update(cx, |this, cx| this.on_file_paths(action, paths, cx));
}
}
})
.detach();
}
FileAction::ExportProjectFile => {
let current = self
.engine
.read(cx)
.project()
.map(|p| p.path.clone())
.filter(|p| !p.as_os_str().is_empty());
let (directory, suggested) = match current {
Some(path) => (
path.parent()
.map(|dir| dir.to_path_buf())
.unwrap_or_else(|| PathBuf::from(".")),
path
.file_name()
.map(|name| name.to_string_lossy().into_owned()),
),
None => (PathBuf::from("."), None),
};
let receiver = cx.prompt_for_new_path(&directory, suggested.as_deref());
cx.spawn(async move |this, cx| {
if let Ok(Ok(Some(path))) = receiver.await {
this.update(cx, |this, cx| {
this.on_file_paths(FileAction::ExportProjectFile, vec![path], cx);
});
}
})
.detach();
}
// The manager's export prompts in `open_manager_export` (it needs
// the selection's suggested filename).
FileAction::ExportProject => {}
}
}
/// Applies paths picked in the platform dialog through the engine, using
/// the action's routing (open / import / export).
fn on_file_paths(&mut self, action: FileAction, paths: Vec<PathBuf>, cx: &mut Context<Self>) {
// The manager's library import/export refresh the open manager and
// report failures in its status line.
match action {
FileAction::ImportProject => {
let Some(path) = paths.first() else {
return;
};
let result = self
.engine
.update(cx, |engine, _cx| engine.library_import_project(path.clone()));
match result {
Ok(uuid) => {
println!("[manager] imported \"{}\" as {uuid}", path.display());
self.reload_manager(cx);
}
Err(err) => self.manager_status(err, cx),
}
return;
}
FileAction::ExportProject => {
let (Some(uuid), Some(path)) = (self.pending_export.take(), paths.first()) else {
return;
};
let result = self
.engine
.update(cx, |engine, _cx| engine.library_export_project(&uuid, path.clone()));
match result {
Ok(()) => println!("[manager] exported to \"{}\"", path.display()),
Err(err) => self.manager_status(err, cx),
}
return;
}
_ => {}
}
let result = self.engine.update(cx, |engine, cx| match action {
FileAction::Open => match paths.first() {
Some(path) => engine.open_project_path(path.clone(), cx),
None => Ok(()),
},
FileAction::ExportProjectFile => match paths.first() {
Some(path) => engine.export_project_path(path.clone(), cx),
None => Ok(()),
},
FileAction::ImportFootage => {
// Import accepts several files at once; keep the first failure
// for the log after the rest have been attempted.
let mut first_error = None;
for path in paths {
if let Err(err) = engine.import_footage(path.clone(), cx) {
first_error.get_or_insert(err);
}
}
match first_error {
Some(err) => Err(err),
None => Ok(()),
}
}
// Handled above (the manager's library import/export).
FileAction::ImportProject | FileAction::ExportProject => Ok(()),
});
if let Err(err) = result {
println!("[file] {action:?} failed: {err}");
}
}
/// Opens the preferences dialog. Theme/language selections emit
/// [`crate::dialogs::PreferencesEvent`]s, applied to the shell chrome
/// immediately; the typed cache directory commits when the dialog closes.
pub fn open_preferences(&mut self, cx: &mut Context<Self>) {
self.spawn_modal(cx, |window, app| {
let content = app.new(|cx| PreferencesContent::new(window, cx));
let modal = app.new(|cx| {
Modal::new(
modal_ids::PREFERENCES,
ModalOptions::new(crate::i18n::tr("preferences.title"), px(480.0))
.with_button(DialogButton::primary(crate::i18n::tr("dialog.close"))),
window,
cx,
)
.with_content(content.clone())
});
ModalState::Preferences { modal, content }
});
if let ModalState::Preferences { content, .. } = &self.modal {
let content = content.clone();
cx.subscribe(
&content,
|this, _content, event: &crate::dialogs::PreferencesEvent, cx| match *event {
crate::dialogs::PreferencesEvent::ThemeChanged(dark) => {
this.apply_dark(dark, cx);
}
crate::dialogs::PreferencesEvent::LanguageChanged => {
this.rebuild_menu_bar(cx);
cx.notify();
}
},
)
.detach();
}
}
/// Commits the preferences dialog's free-text fields (the cache
/// directory path) before the modal closes.
fn commit_preferences(&mut self, cx: &mut Context<Self>) {
if let ModalState::Preferences { content, .. } = &self.modal {
content.update(cx, |content, cx| content.commit_cache_dir(cx));
}
}
/// Opens the proxy settings dialog (Tools > Proxy Settings; the C++
/// `ProxyDialog`): the global generation settings plus the footage
/// proxy list with Generate / Delete buttons.
fn open_proxy_dialog(&mut self, cx: &mut Context<Self>) {
if !matches!(self.modal, ModalState::None) {
return;
}
let engine = self.engine.clone();
self.spawn_modal(cx, move |window, app| {
let content =
app.new(|cx| crate::dialogs::ProxyDialogContent::new(engine, window, cx));
let modal = app.new(|cx| {
Modal::new(
modal_ids::PROXY,
ModalOptions::new(crate::i18n::tr("proxydialog.title"), px(560.0))
.with_button(DialogButton::new(
crate::i18n::tr("proxydialog.generate"),
gpui_widgets::dialog::DialogButtonRole::Secondary,
))
.with_button(DialogButton::new(
crate::i18n::tr("proxydialog.delete"),
gpui_widgets::dialog::DialogButtonRole::Secondary,
))
.with_button(DialogButton::primary(crate::i18n::tr(
"proxydialog.close",
))),
window,
cx,
)
.with_content(content.clone())
});
ModalState::Proxy { modal, content }
});
}
/// Opens the export dialog.
fn open_export_dialog(&mut self, cx: &mut Context<Self>) {
if self.engine.read(cx).current_sequence().is_none() {
println!("[export] no sequence open");
return;
}
let default_path = self.default_export_path(cx);
self.spawn_modal(cx, move |window, app| {
let content = app.new(|cx| ExportDialogContent::new(window, cx));
content.update(app, |content, cx| {
content.set_path(default_path.clone(), cx)
});
let modal = app.new(|cx| {
Modal::new(
modal_ids::EXPORT,
ModalOptions::new(crate::i18n::tr("export.title"), px(440.0))
.with_button(DialogButton::primary(crate::i18n::tr("export.run")))
.with_button(DialogButton::cancel(crate::i18n::tr("dialog.cancel"))),
window,
cx,
)
.with_content(content.clone())
});
ModalState::Export { modal, content }
});
}
/// A default output path for the export dialog: the project name with
/// the format's extension, next to the project file.
fn default_export_path(&self, cx: &App) -> String {
let project = self.engine.read(cx).project();
let name = project
.map(|p| p.name.clone())
.filter(|n| !n.is_empty())
.unwrap_or_else(|| "untitled".to_string());
let dir = project
.and_then(|p| p.path.parent().map(|d| d.to_path_buf()))
.unwrap_or_else(|| PathBuf::from("."));
dir.join(format!("{name}.mp4"))
.to_string_lossy()
.into_owned()
}
/// Starts the export from the export dialog's state and swaps the dialog
/// for the progress dialog.
fn begin_export(&mut self, cx: &mut Context<Self>) {
let ModalState::Export { content, .. } = &self.modal else {
return;
};
let format = content.read(cx).format(cx);
let ext = content.read(cx).extension(cx);
let mut path = content.read(cx).path(cx).to_string();
if path.trim().is_empty() {
return;
}
// Append the format's extension when the user left it off.
let has_ext = std::path::Path::new(&path)
.extension()
.map(|e| !e.to_string_lossy().is_empty())
.unwrap_or(false);
if !has_ext {
path = format!("{path}.{ext}");
}
let result = self.engine.update(cx, |engine, _cx| {
engine.start_export(format, PathBuf::from(&path))
});
match result {
Ok(session) => {
self.export = Some(ExportRun { session });
self.spawn_modal(cx, |window, app| {
let (modal, content) = progress_dialog(
modal_ids::EXPORT_PROGRESS,
crate::i18n::tr("export.progress.title"),
crate::i18n::tr("export.progress.label"),
window,
app,
);
ModalState::Progress { modal, content }
});
}
Err(err) => {
println!("[export] failed to start: {err}");
self.close_modal(cx);
}
}
}
/// Cancels the running export (the task aborts at the next frame).
fn cancel_export(&mut self, cx: &mut Context<Self>) {
if let Some(run) = &self.export {
(run.session.cancel)();
}
let _ = cx;
}
/// Drains the export progress events on the tick loop: updates the
/// progress bar and closes the dialog when the task finishes.
fn poll_export(&mut self, cx: &mut Context<Self>) {
let Some(run) = &self.export else {
return;
};
let mut events = Vec::new();
while let Ok(event) = run.session.events.try_recv() {
events.push(event);
}
if events.is_empty() {
return;
}
let mut finished: Option<(bool, String)> = None;
for event in events {
match event {
crate::oakui::ExportEvent::Started => {}
crate::oakui::ExportEvent::Progress(fraction) => {
if let ModalState::Progress { content, .. } = &self.modal {
let fraction = fraction as f32;
content.update(cx, |content, cx| content.set_progress(fraction, cx));
}
}
crate::oakui::ExportEvent::Finished(ok, err) => finished = Some((ok, err)),
}
}
if let Some((ok, err)) = finished {
self.export = None;
self.modal = ModalState::None;
if ok {
println!("[export] finished");
} else {
println!("[export] failed: {err}");
}
cx.notify();
}
}
/// Routes a modal dialog event.
fn on_modal(&mut self, event: &ModalEvent, cx: &mut Context<Self>) {
match event {
ModalEvent::ButtonClicked { control, button } => match *control {
modal_ids::EXPORT => {
if *button == 0 {
self.begin_export(cx);
} else {
self.close_modal(cx);
}
}
modal_ids::EXPORT_PROGRESS => {
if *button == 1 {
// Cancel button: ask the task to abort; the finished
// event closes the dialog.
self.cancel_export(cx);
}
}
modal_ids::PREFERENCES => {
self.commit_preferences(cx);
self.close_modal(cx);
}
modal_ids::MANAGER => self.close_modal(cx),
modal_ids::MANAGER_RENAME => {
if *button == 0 {
self.confirm_manager_rename(cx);
} else {
self.back_to_manager(cx);
}
}
modal_ids::MANAGER_DELETE => {
if *button == 0 {
self.confirm_manager_delete(cx);
} else {
self.back_to_manager(cx);
}
}
modal_ids::PROXY => {
if let ModalState::Proxy { content, .. } = &self.modal {
let content = content.clone();
match *button {
0 => content.update(cx, |dialog, cx| dialog.generate(cx)),
1 => content.update(cx, |dialog, cx| dialog.delete(cx)),
_ => {
content.update(cx, |dialog, cx| dialog.accept(cx));
self.close_modal(cx);
}
}
}
}
_ => {}
},
ModalEvent::Dismissed { control } => match *control {
modal_ids::EXPORT_PROGRESS => {
// Escape cancels the running export and closes the dialog.
self.cancel_export(cx);
self.close_modal(cx);
}
// Escape from a manager sub-dialog returns to the manager.
modal_ids::MANAGER_RENAME | modal_ids::MANAGER_DELETE => {
self.back_to_manager(cx);
}
modal_ids::PREFERENCES => {
self.commit_preferences(cx);
self.close_modal(cx);
}
// Escape closes the proxy dialog without applying (the
// Close button is the apply path, like the C++ accept()).
modal_ids::PROXY => self.close_modal(cx),
_ => self.close_modal(cx),
},
}
}
}
impl<E: AppEngine> Render for OakApp<E> {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let mut root = div()
.size_full()
.flex()
.flex_col()
.track_focus(&self.shell_focus);
// The shell's keyboard dispatch layer: the global key bindings
// (registered in `new` from the action registry) dispatch gpui
// actions, which bubble up from the focused widget and land here —
// one listener per registry action, all routing through the same
// `dispatch_action_id` the menu clicks use.
for entry in crate::actions::REGISTRY {
let action = entry.action;
root = root.on_boxed_action(
&*(entry.build)(),
cx.listener(move |this, _action: &dyn gpui::Action, _window, cx| {
cx.stop_propagation();
this.on_action_dispatched(action, cx);
}),
);
}
let mut root = root
.child(self.menu_bar.clone())
.child(div().flex_1().min_h_0().child(self.dock.clone()))
.child(self.status_bar.clone());
if let Some(modal) = self.modal.modal_entity() {
root = root.child(modal);
}
root
}
}
/// The dynamic menu-bar state the registry-driven menu tree reads its
/// checkmarks from (theme, active tool, snapping, loop, show-all,
/// full-screen).
#[derive(Clone, Copy)]
struct MenuState {
dark: bool,
active_tool: Tool,
snapping: bool,
loop_playback: bool,
show_all: bool,
full_screen: bool,
use_proxy_media: bool,
}
impl MenuState {
/// The shell's startup state (the timeline widget defaults to snapping
/// on, the pointer tool is active).
fn new(dark: bool) -> Self {
Self {
dark,
active_tool: Tool::Pointer,
snapping: true,
loop_playback: false,
show_all: false,
full_screen: false,
use_proxy_media: oakcommon::configstore::ConfigStore::instance()
.get_bool(None, "UseProxyMedia", 1)
!= 0,
}
}
}
/// One menu item straight from the registry — delegates to
/// [`menus::shared::action_item`](crate::menus::shared::action_item) so the
/// menu bar and the context menus build items the same way.
fn menu_item(action: ActionId) -> MenuItem {
crate::menus::shared::action_item(action)
}
/// Builds the menu bar entries (文件/编辑/视图/回放/序列/窗口/工具/帮助) from
/// the action registry (`src/actions.rs`): this function decides placement,
/// grouping and separators, while ids, labels and shortcut annotations all
/// come from the registry — so a menu click and a key press can never
/// diverge. All labels come from the [`crate::i18n`] tables, so rebuilding
/// the menu bar after a language switch repaints it in the new language;
/// `state` drives the dynamic checkmarks (theme, tool, snapping, loop, …).
fn make_menus(state: MenuState) -> Vec<MenuBarEntry> {
use crate::actions::ActionId as A;
use crate::i18n::tr;
let theme_submenu = Menu::new(vec![
menu_item(A::ThemeDark).with_checked(state.dark),
menu_item(A::ThemeLight).with_checked(!state.dark),
]);
let language = crate::i18n::language();
let language_submenu = Menu::new(vec![
menu_item(A::LangZh).with_checked(language == crate::i18n::Language::ZhCN),
menu_item(A::LangEn).with_checked(language == crate::i18n::Language::EnUs),
]);
// 工具: the mutually exclusive tool group in the registry's order, the
// add tool growing its addable-items submenu, then snapping + proxy.
let mut tools: Vec<MenuItem> = Vec::new();
for tool in [
Tool::Pointer,
Tool::TrackSelect,
Tool::Edit,
Tool::Ripple,
Tool::Rolling,
Tool::Razor,
Tool::Slip,
Tool::Slide,
Tool::Hand,
Tool::Zoom,
Tool::Transition,
Tool::Add,
Tool::Record,
] {
let mut item = menu_item(tool.action()).with_checked(tool == state.active_tool);
if tool == Tool::Add {
item = item.with_submenu(Menu::new(vec![
menu_item(A::AddEmpty),
menu_item(A::AddBars),
menu_item(A::AddShape),
menu_item(A::AddSolid),
menu_item(A::AddTitle),
menu_item(A::AddTone),
menu_item(A::AddSubtitle),
]));
}
tools.push(item);
}
tools.push(menu_item(A::Snapping).with_checked(state.snapping).separated());
tools.push(menu_item(A::UseProxyMedia).with_checked(state.use_proxy_media));
tools.push(menu_item(A::ProxySettings));
vec![
MenuBarEntry::new(
tr("menu.file"),
Menu::new(vec![
// 新建 ▸ group: the parent carries the first child's id (the
// same shape the theme/language submenus use).
MenuItem::new(A::NewProject.menu_id(), tr("menu.file.new")).with_submenu(
Menu::new(vec![
menu_item(A::NewProject),
menu_item(A::NewSequence),
menu_item(A::NewFolder),
]),
),
menu_item(A::OpenFromLibrary),
menu_item(A::OpenProject),
// 最近打开 ▸ (no recent-files backend yet: only the clear item).
MenuItem::new(A::ClearOpenRecent.menu_id(), tr("menu.file.open_recent"))
.with_submenu(Menu::new(vec![menu_item(A::ClearOpenRecent)])),
menu_item(A::ProjectManager).separated(),
menu_item(A::Import),
menu_item(A::SaveProject),
menu_item(A::SaveProjectAs),
menu_item(A::Revert),
menu_item(A::Export).separated(),
menu_item(A::ProjectProperties),
menu_item(A::CloseProject).separated(),
menu_item(A::Exit),
]),
),
MenuBarEntry::new(
tr("menu.edit"),
Menu::new(vec![
menu_item(A::Undo),
menu_item(A::Redo).separated(),
menu_item(A::Cut),
menu_item(A::Copy),
menu_item(A::Paste),
menu_item(A::PasteInsert),
menu_item(A::Duplicate).separated(),
menu_item(A::Rename),
menu_item(A::Delete),
menu_item(A::RippleDelete).separated(),
menu_item(A::SplitAtPlayhead),
menu_item(A::SpeedDuration),
menu_item(A::DefaultTransition),
menu_item(A::LinkUnlink),
menu_item(A::EnableDisable),
menu_item(A::Nest).separated(),
menu_item(A::SelectAll),
menu_item(A::DeselectAll).separated(),
menu_item(A::Insert),
menu_item(A::Overwrite).separated(),
menu_item(A::RippleToIn),
menu_item(A::RippleToOut),
menu_item(A::EditToIn),
menu_item(A::EditToOut).separated(),
menu_item(A::NudgeLeft),
menu_item(A::NudgeRight).separated(),
menu_item(A::MoveInToPlayhead),
menu_item(A::MoveOutToPlayhead).separated(),
menu_item(A::SetInPoint),
menu_item(A::SetOutPoint),
menu_item(A::ResetIn),
menu_item(A::ResetOut),
menu_item(A::ClearInOut).separated(),
menu_item(A::DeleteInOut),
menu_item(A::RippleDeleteInOut).separated(),
menu_item(A::Marker),
]),
),
MenuBarEntry::new(
tr("menu.view"),
Menu::new(vec![
menu_item(A::ThemeDark).with_submenu(theme_submenu),
menu_item(A::LangZh).with_submenu(language_submenu),
menu_item(A::ZoomIn).separated(),
menu_item(A::ZoomOut),
menu_item(A::IncreaseTrackHeight),
menu_item(A::DecreaseTrackHeight).separated(),
menu_item(A::ToggleShowAll).with_checked(state.show_all),
menu_item(A::FullScreen).with_checked(state.full_screen),
menu_item(A::FullScreenViewer).separated(),
menu_item(A::Preferences),
]),
),
MenuBarEntry::new(
tr("menu.playback"),
Menu::new(vec![
menu_item(A::PlayPause),
menu_item(A::PlayInToOut),
menu_item(A::Loop).with_checked(state.loop_playback).separated(),
menu_item(A::ShuttleLeft),
menu_item(A::ShuttleStop),
menu_item(A::ShuttleRight).separated(),
menu_item(A::PrevFrame),
menu_item(A::NextFrame).separated(),
menu_item(A::GoToStart),
menu_item(A::GoToEnd),
menu_item(A::GoToPrevCut),
menu_item(A::GoToNextCut).separated(),
menu_item(A::GoToIn),
menu_item(A::GoToOut),
]),
),
MenuBarEntry::new(
tr("menu.sequence"),
Menu::new(vec![
menu_item(A::AddVideoTrack),
menu_item(A::AddAudioTrack),
menu_item(A::RemoveTrack).separated(),
menu_item(A::SetWorkArea),
menu_item(A::ClearWorkArea).separated(),
menu_item(A::RemoveMarker).separated(),
menu_item(A::SeqCache),
menu_item(A::SeqCacheInOut),
menu_item(A::SeqCacheClear).separated(),
menu_item(A::SequenceSettings).disabled(),
]),
),
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),
]),
),
MenuBarEntry::new(tr("menu.tools"), Menu::new(tools)),
MenuBarEntry::new(
tr("menu.help"),
Menu::new(vec![
menu_item(A::ActionSearch),
menu_item(A::Feedback).separated(),
menu_item(A::About),
]),
),
]
}
/// Builds the menu bar with the startup [`MenuState`] (tests).
#[cfg(test)]
pub(crate) fn make_menus_for_test() -> Vec<MenuBarEntry> {
make_menus(MenuState::new(true))
}
/// Command-line arguments the app accepts.
#[derive(Debug, Clone, Default)]
struct AppArgs {
/// A project file to open at startup.
project: Option<PathBuf>,
/// Force the mock engine.
mock: bool,
}
impl AppArgs {
/// Parses `std::env::args` plus the `OAK_ENGINE` override:
/// `oakapp [project.ove] [--mock]`.
fn from_env() -> Self {
let mut args = AppArgs::default();
for arg in std::env::args_os().skip(1) {
let text = arg.to_string_lossy();
match text.as_ref() {
"--mock" => args.mock = true,
"--help" | "-h" => {
println!("oakapp — Oak Video Editor");
println!("usage: oakapp [project.ove] [--mock]");
println!(" --mock use the mock engine (or set OAK_ENGINE=mock)");
std::process::exit(0);
}
_ if args.project.is_none() => args.project = Some(arg.into()),
other => println!("[app] ignoring unknown argument {other:?}"),
}
}
if std::env::var("OAK_ENGINE")
.map(|v| v.eq_ignore_ascii_case("mock"))
.unwrap_or(false)
{
args.mock = true;
}
args
}
}
/// Builds the app root entity for the chosen backend.
fn build_root<E: AppEngine>(
window: &mut Window,
initial: Option<PathBuf>,
cx: &mut App,
) -> Entity<OakApp<E>> {
cx.new(|cx| OakApp::new(window, initial, cx))
}
/// The crate entry point: applies the olive-dark theme and opens the main
/// window, running on the real engine by default (mock with `--mock` /
/// `OAK_ENGINE=mock` / the `mock-engine` feature).
pub fn run() {
let args = AppArgs::from_env();
let use_mock = args.mock || cfg!(feature = "mock-engine");
if use_mock {
run_with::<MockEngine>(args.clone());
} else {
run_with::<RealEngine>(args);
}
}
/// Runs the app window with `E` as the engine backend.
fn run_with<E: AppEngine>(args: AppArgs) {
let initial = args.project.clone();
gpui_platform::application().run(move |cx: &mut App| {
// Load the persisted preferences (config.ini) before anything reads
// them — the language, the theme, the storage backend and the audio
// devices all come from the config store.
crate::oakui::real::config_load();
// Restore the persisted UI language (config `Language` key) before the
// first window renders.
crate::i18n::init();
// M13 D4: enable the write-through project library (SQLite at the
// default location) unless the user configured the backend
// explicitly.
crate::oakui::real::configure_storage();
// Bring up the audio manager and apply the persisted device choices
// (without an instance, playback pushes fail silently).
crate::oakui::real::audio_init_from_config();
cx.init_colors();
let bounds = Bounds::centered(None, size(px(1600.0), px(900.0)), cx);
let initial = initial.clone();
let show_manager = initial.is_none();
let mut root_slot = None;
let window = cx
.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
..Default::default()
},
|window, cx| {
// Compact pro-app text metrics: gpui's default rem is
// 16px (desktop-app large); 14px matches the design's
// density. All rem-based text scales; px spacing is
// unaffected.
window.set_rem_size(px(14.0));
let root = build_root::<E>(window, initial, cx);
root_slot = Some(root.clone());
root
},
)
.expect("failed to open the main window");
let _ = window;
// No project on the command line: the DaVinci-style project manager
// greets instead of an empty shell. Drives the ROOT ENTITY (not the
// window handle): a window update borrows the window, and building a
// modal inside it would re-enter it (spawn_modal needs a free
// `update_window`).
if show_manager {
if let Some(root) = &root_slot {
root.update(cx, |app, cx| app.show_project_manager(cx));
}
}
cx.activate(true);
cx.on_window_closed(|cx, _| {
if cx.windows().is_empty() {
// Persist the preferences (config.ini), then drain the
// write-through backlog (save + snapshot of every still-bound
// project) and stop oakstorage's snapshot thread.
crate::oakui::real::config_save();
crate::oakui::real::storage_flush();
cx.quit();
}
})
.detach();
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::oakui::EngineGateway as _;
use gpui::timeline::TimelineDataSource as _;
use gpui::{px, size, ExternalPaths, FileDropEvent, TestAppContext, VisualTestContext};
/// The 视图/View menu carries a 语言/Language submenu whose items are
/// labeled in their own language and whose checkmark follows the active
/// language — and the whole menu bar flips language with `i18n`.
#[test]
fn language_menu_tracks_the_active_language() {
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
let view_entry = |dark: bool| -> MenuBarEntry {
make_menus(MenuState::new(dark))
.into_iter()
.find(|entry| entry.title == crate::i18n::tr("menu.view"))
.expect("视图/View menu exists")
};
// The language submenu sits under 视图/View.
let language_item = |entry: &MenuBarEntry| -> Menu {
entry
.menu
.items
.iter()
.find(|item| item.id == menu_ids::LANG_ZH)
.map(|item| item.submenu.clone().map(|m| *m).unwrap_or_default())
.expect("语言/Language submenu exists")
};
crate::i18n::set_language(crate::i18n::Language::EnUs);
let submenu = language_item(&view_entry(true));
let zh = submenu
.items
.iter()
.find(|i| i.id == menu_ids::LANG_ZH)
.expect("zh item");
let en = submenu
.items
.iter()
.find(|i| i.id == menu_ids::LANG_EN)
.expect("en item");
assert_eq!(zh.label, "简体中文");
assert_eq!(en.label, "English");
assert_eq!(zh.checked, Some(false));
assert_eq!(en.checked, Some(true), "en-US is active → checked");
crate::i18n::set_language(crate::i18n::Language::ZhCN);
let submenu = language_item(&view_entry(true));
let zh = submenu
.items
.iter()
.find(|i| i.id == menu_ids::LANG_ZH)
.expect("zh item");
let en = submenu
.items
.iter()
.find(|i| i.id == menu_ids::LANG_EN)
.expect("en item");
assert_eq!(zh.checked, Some(true), "zh-CN is active → checked");
assert_eq!(en.checked, Some(false));
// The menu titles themselves are localized.
assert_eq!(view_entry(true).title, "视图(V)");
crate::i18n::set_language(crate::i18n::Language::EnUs);
assert_eq!(view_entry(true).title, "View(V)");
}
/// The theme submenu's checkmark follows the `dark` flag.
#[test]
fn theme_menu_checkmark_follows_dark_flag() {
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
let dark_item = |dark: bool| -> gpui_widgets::menu::MenuItem {
let entries = make_menus(MenuState::new(dark));
let view = entries
.iter()
.find(|entry| entry.title == crate::i18n::tr("menu.view"))
.expect("视图/View menu");
let theme = view
.menu
.items
.iter()
.find(|i| i.id == menu_ids::THEME_DARK)
.and_then(|i| i.submenu.clone())
.expect("theme submenu");
theme
.items
.into_iter()
.find(|i| i.id == menu_ids::THEME_DARK)
.expect("Olive Dark item")
};
assert_eq!(dark_item(true).checked, Some(true));
assert_eq!(dark_item(false).checked, Some(false));
}
/// 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,
/// across both languages.
#[test]
fn file_and_edit_menus_cover_the_project_lifecycle() {
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
let entry = |title: &str| -> MenuBarEntry {
make_menus_for_test()
.into_iter()
.find(|entry| entry.title == title)
.expect("menu exists")
};
crate::i18n::set_language(crate::i18n::Language::EnUs);
let file = entry("File(F)");
for id in [
menu_ids::NEW_PROJECT,
menu_ids::OPEN_FROM_LIBRARY,
menu_ids::OPEN_PROJECT,
menu_ids::PROJECT_MANAGER,
menu_ids::EXPORT_PROJECT,
menu_ids::CLOSE,
menu_ids::EXPORT,
menu_ids::QUIT,
] {
assert!(
file.menu.items.iter().any(|item| item.id == id),
"File menu is missing item {id}"
);
}
let edit = entry("Edit(E)");
for id in [
menu_ids::UNDO,
menu_ids::REDO,
menu_ids::DELETE,
menu_ids::RIPPLE_DELETE,
] {
assert!(
edit.menu.items.iter().any(|item| item.id == id),
"Edit menu is missing item {id}"
);
}
// The same ids exist in the zh-CN menu bar.
crate::i18n::set_language(crate::i18n::Language::ZhCN);
let file = entry("文件(F)");
assert!(file
.menu
.items
.iter()
.any(|item| item.id == menu_ids::EXPORT_PROJECT));
}
/// Opening 视图 → Preferences… must not crash: the dialog content and the
/// modal are built on the main window and the shell state swaps over the
/// entity's weak handle (regression test for the Preferences crash).
#[gpui::test]
async fn preferences_dialog_opens_without_crashing(cx: &mut TestAppContext) {
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
crate::i18n::set_language(crate::i18n::Language::EnUs);
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(1600.0), px(900.0)), |window, cx| {
OakApp::<MockEngine>::new(window, None, cx)
});
cx.run_until_parked();
let root = window.root(cx).expect("app root");
cx.update(|app| root.update(app, |app, cx| app.on_menu(menu_ids::PREFERENCES, cx)));
cx.run_until_parked();
// Force a draw so render-time panics in the dialog content surface.
cx.update_window(window.into(), |_root, window, cx| {
window.draw(cx).clear();
})
.expect("window is still open");
let has_modal =
cx.read(|app| matches!(root.read(app).modal, ModalState::Preferences { .. }));
assert!(
has_modal,
"preferences modal should be shown after the menu action"
);
}
// -------------------------------------------------------------------
// Keyboard shortcuts (M12 P5c)
// -------------------------------------------------------------------
/// Every registry action with a default key exists as a menu item
/// (recursing into submenus), so a key press can never dispatch a dead
/// action.
#[test]
fn every_shortcut_maps_to_a_menu_item() {
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
crate::i18n::set_language(crate::i18n::Language::EnUs);
fn collect(menu: &Menu, out: &mut Vec<usize>) {
for item in &menu.items {
out.push(item.id);
if let Some(sub) = &item.submenu {
collect(sub, out);
}
}
}
let mut ids = Vec::new();
for entry in make_menus_for_test() {
collect(&entry.menu, &mut ids);
}
// Context-menu-only actions (the timeline clip menu's synchronize
// group) appear in the clip menu instead of the menu bar.
collect(
&crate::panels::timeline::clip_menu(
crate::oakui::engine::SyncEligibility::default(),
&[],
None,
),
&mut ids,
);
for entry in crate::actions::REGISTRY {
if entry.default_keys.is_empty() {
continue;
}
assert!(
ids.contains(&entry.menu_id()),
"shortcut {} → action {} has no menu item",
entry.default_keys[0],
entry.cpp_id
);
}
}
/// The menu's shortcut labels mirror the registry's display strings (a
/// drift between them would show the user the wrong key). Both sides
/// come from [`crate::actions::display_shortcut`], so this holds on
/// every platform.
#[test]
fn menu_shortcut_labels_match_the_table() {
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
crate::i18n::set_language(crate::i18n::Language::EnUs);
fn walk(menu: &Menu) -> Vec<(usize, Option<gpui::SharedString>)> {
let mut out = Vec::new();
for item in &menu.items {
out.push((item.id, item.shortcut.clone()));
if let Some(sub) = &item.submenu {
out.extend(walk(sub));
}
}
out
}
for entry in make_menus_for_test() {
for (id, label) in walk(&entry.menu) {
let Some(label) = label else { continue };
let Some(registry_entry) = crate::actions::entry_for_menu_id(id) else {
panic!("menu item {id} shows {label} but is not a registry action");
};
let expected = crate::actions::display_shortcut(registry_entry.action)
.unwrap_or_else(|| panic!("menu item {id} shows {label} but has no shortcut"));
assert_eq!(
label.as_ref(),
expected,
"shortcut label drift on menu item {id}"
);
}
}
}
/// Pressing space on the shell toggles program playback (the keystroke
/// bubbles to the shell's key listener and dispatches 回放 → 播放/暂停).
#[gpui::test]
async fn space_toggles_program_playback(cx: &mut TestAppContext) {
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
let (window, root) = mock_shell(cx);
let playing = cx.read(|app| root.read(app).program_clock.read(app).is_playing());
assert!(!playing, "the shell starts paused");
cx.dispatch_keystroke(
window.into(),
gpui::Keystroke::parse("space").unwrap(),
);
cx.run_until_parked();
let playing = cx.read(|app| root.read(app).program_clock.read(app).is_playing());
assert!(playing, "space starts program playback");
cx.dispatch_keystroke(
window.into(),
gpui::Keystroke::parse("space").unwrap(),
);
cx.run_until_parked();
let playing = cx.read(|app| root.read(app).program_clock.read(app).is_playing());
assert!(!playing, "space again pauses program playback");
}
/// The registry's default keys dispatch their actions end to end:
/// shuttle (j/k/l), in/out points (i/o), marker (m), snapping (s),
/// timeline zoom (=/-), track height (⌘=) and — with the timeline
/// focused — select-all/deselect-all (⌘A/⌘⇧A) plus the fall-through of
/// the still-unwired ripple-to-in/out (q/w).
#[gpui::test]
async fn keymap_defaults_dispatch_their_actions(cx: &mut TestAppContext) {
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
let (window, root) = mock_shell(cx);
let key = |keystroke: &str| gpui::Keystroke::parse(keystroke).unwrap();
// --- shuttle: l plays, k stops, j steps back ----------------------
cx.dispatch_keystroke(window.into(), key("l"));
cx.run_until_parked();
assert!(
cx.read(|app| root.read(app).program_clock.read(app).is_playing()),
"l starts playback"
);
cx.dispatch_keystroke(window.into(), key("k"));
cx.run_until_parked();
assert!(
!cx.read(|app| root.read(app).program_clock.read(app).is_playing()),
"k stops playback"
);
for _ in 0..10 {
cx.dispatch_keystroke(window.into(), key("right"));
}
cx.dispatch_keystroke(window.into(), key("j"));
cx.run_until_parked();
assert_eq!(
cx.read(|app| root.read(app).program_clock.read(app).current_frame()),
Frame(9),
"j steps one frame back from frame 10"
);
// --- in/out points: i marks the in at 9, o the out at 15 ----------
cx.dispatch_keystroke(window.into(), key("i"));
for _ in 0..6 {
cx.dispatch_keystroke(window.into(), key("right"));
}
cx.dispatch_keystroke(window.into(), key("o"));
cx.run_until_parked();
let workarea = cx.read(|app| root.read(app).engine.read(app).workarea());
assert_eq!(workarea, Some((Frame(9), Frame(15))), "i/o set the work area");
// --- marker: m adds one at the playhead ---------------------------
cx.dispatch_keystroke(window.into(), key("m"));
cx.run_until_parked();
let markers = cx.read(|app| root.read(app).engine.read(app).markers());
assert!(
markers.iter().any(|marker| marker.frame == Frame(15)),
"m adds a marker at the playhead"
);
// --- snapping: s toggles the timeline's snap flag ------------------
cx.dispatch_keystroke(window.into(), key("s"));
cx.run_until_parked();
assert!(
!cx.read(|app| root.read(app).timeline.read(app).state.snap_enabled),
"s toggles snapping off"
);
// --- zoom: = zooms in, - zooms out ---------------------------------
let zoom_before = cx.read(|app| root.read(app).timeline.read(app).state.zoom);
cx.dispatch_keystroke(window.into(), key("="));
cx.run_until_parked();
let zoom_in = cx.read(|app| root.read(app).timeline.read(app).state.zoom);
assert!(zoom_in > zoom_before, "= zooms the timeline in");
cx.dispatch_keystroke(window.into(), key("-"));
cx.run_until_parked();
let zoom_out = cx.read(|app| root.read(app).timeline.read(app).state.zoom);
assert!(zoom_out < zoom_in, "- zooms the timeline out");
// --- track height: ⌘= grows the tracks by 8px (24160px clamp) ----
let height = |cx: &mut TestAppContext| -> f32 {
cx.read(|app| {
root.read(app)
.engine
.read(app)
.track(0)
.map(|track| f32::from(track.height()))
.unwrap_or_default()
})
};
let height_before = height(cx);
cx.dispatch_keystroke(window.into(), key("secondary-="));
cx.run_until_parked();
assert_eq!(height(cx), height_before + 8.0, "⌘= grows the tracks");
// --- focused-panel routing: ⌘A / ⌘⇧A hit the timeline panel --------
cx.update(|app| root.update(app, |app, _cx| app.focused_panel = Some(TIMELINE)));
cx.dispatch_keystroke(window.into(), key("secondary-a"));
cx.run_until_parked();
let selected = cx.read(|app| root.read(app).timeline.read(app).selection().len());
assert!(selected > 0, "⌘A selects every clip via the timeline panel");
cx.dispatch_keystroke(window.into(), key("secondary-shift-a"));
cx.run_until_parked();
let selected = cx.read(|app| root.read(app).timeline.read(app).selection().len());
assert_eq!(selected, 0, "⌘⇧A deselects via the timeline panel");
// q/w (ripple-to-in/out) reach the panel but are not wired yet —
// they fall through to the shell's placeholder, state untouched.
cx.dispatch_keystroke(window.into(), key("q"));
cx.dispatch_keystroke(window.into(), key("w"));
cx.run_until_parked();
let workarea = cx.read(|app| root.read(app).engine.read(app).workarea());
assert_eq!(
workarea,
Some((Frame(9), Frame(15))),
"q/w are inert placeholders for now"
);
}
/// ⌘Z on the shell dispatches 编辑 → 撤销 to the engine, and ⌘K splits
/// at the playhead.
#[gpui::test]
async fn edit_shortcuts_dispatch_to_the_engine(cx: &mut TestAppContext) {
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
let (window, root) = mock_shell(cx);
// Move the playhead inside the first clip (the → shortcut steps one
// frame), then split with ⌘K: the mock sequence grows by one clip.
for _ in 0..10 {
cx.dispatch_keystroke(window.into(), gpui::Keystroke::parse("right").unwrap());
}
cx.run_until_parked();
let playhead = cx.read(|app| root.read(app).program_clock.read(app).current_frame());
assert_eq!(playhead, Frame(10), "→ steps the playhead");
let clips_before: usize = cx.read(|app| {
let engine = root.read(app).engine.read(app);
(0..engine.track_count())
.filter_map(|i| engine.track(i))
.map(|t| t.clips().len())
.sum()
});
cx.dispatch_keystroke(
window.into(),
gpui::Keystroke::parse("secondary-k").unwrap(),
);
cx.run_until_parked();
let clips_after: usize = cx.read(|app| {
let engine = root.read(app).engine.read(app);
(0..engine.track_count())
.filter_map(|i| engine.track(i))
.map(|t| t.clips().len())
.sum()
});
assert!(
clips_after > clips_before,
"split at the playhead adds a clip ({clips_before} → {clips_after})"
);
// ⌘Z / ⌘⇧Z reach the engine's undo/redo (the mock counts the calls).
cx.dispatch_keystroke(
window.into(),
gpui::Keystroke::parse("secondary-z").unwrap(),
);
cx.dispatch_keystroke(
window.into(),
gpui::Keystroke::parse("secondary-shift-z").unwrap(),
);
cx.run_until_parked();
let (undo, redo) = cx.read(|app| root.read(app).engine.read(app).undo_redo_calls());
assert_eq!((undo, redo), (1, 1), "⌘Z/⌘⇧Z dispatch undo/redo");
}
/// While a modal dialog is open the shell's shortcuts are inert (the
/// dialog's text fields must never trigger editing actions).
#[gpui::test]
async fn shortcuts_are_suppressed_while_a_modal_is_open(cx: &mut TestAppContext) {
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
let (window, root) = mock_shell(cx);
cx.update(|app| root.update(app, |app, cx| app.on_menu(menu_ids::PREFERENCES, cx)));
cx.run_until_parked();
// ⌘K would split at the playhead, "s" would toggle snapping and
// space would start playback without the modal guard.
let clips_before: usize = cx.read(|app| {
let engine = root.read(app).engine.read(app);
(0..engine.track_count())
.filter_map(|i| engine.track(i))
.map(|t| t.clips().len())
.sum()
});
let snapping_before =
cx.read(|app| root.read(app).timeline.read(app).state.snap_enabled);
cx.dispatch_keystroke(
window.into(),
gpui::Keystroke::parse("secondary-k").unwrap(),
);
cx.dispatch_keystroke(window.into(), gpui::Keystroke::parse("s").unwrap());
cx.dispatch_keystroke(
window.into(),
gpui::Keystroke::parse("space").unwrap(),
);
cx.run_until_parked();
let (clips_after, playing, snapping_after) = cx.read(|app| {
let root = root.read(app);
let clips: usize = (0..root.engine.read(app).track_count())
.filter_map(|i| root.engine.read(app).track(i))
.map(|t| t.clips().len())
.sum();
(
clips,
root.program_clock.read(app).is_playing(),
root.timeline.read(app).state.snap_enabled,
)
});
assert_eq!(clips_after, clips_before, "no split while the modal is open");
assert!(!playing, "no playback toggle while the modal is open");
assert_eq!(
snapping_after, snapping_before,
"no snapping toggle while the modal is open"
);
assert!(
cx.read(|app| matches!(root.read(app).modal, ModalState::Preferences { .. })),
"the modal is still open"
);
}
/// 文件 → 导入素材… opens the *platform* path picker (not the in-window
/// file dialog) and routes the picked path to the engine's import; the
/// mock engine records it, so the async round trip is observable.
#[gpui::test]
async fn import_footage_prompts_and_routes_the_picked_path(cx: &mut TestAppContext) {
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
crate::i18n::set_language(crate::i18n::Language::EnUs);
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(1600.0), px(900.0)), |window, cx| {
OakApp::<MockEngine>::new(window, None, cx)
});
cx.run_until_parked();
let root = window.root(cx).expect("app root");
cx.update(|app| root.update(app, |app, cx| app.on_menu(menu_ids::IMPORT_FOOTAGE, cx)));
cx.run_until_parked();
assert!(
cx.did_prompt_for_paths(),
"the platform path picker should be shown"
);
// Answer the picker with one file: the async continuation must land it
// in the engine's import.
let picked = PathBuf::from("/media/raw/interview.mov");
cx.simulate_path_prompt_response({
let picked = picked.clone();
move |options| {
assert!(options.multiple, "import allows multiple selection");
Some(vec![picked])
}
});
cx.run_until_parked();
let imported =
cx.read(|app| root.read(app).engine.read(app).imported_footage().to_vec());
assert_eq!(imported, vec![picked]);
// Cancelling the picker imports nothing.
cx.update(|app| root.update(app, |app, cx| app.on_menu(menu_ids::IMPORT_FOOTAGE, cx)));
cx.run_until_parked();
cx.simulate_path_prompt_response(|_options| None);
cx.run_until_parked();
let imported = cx.read(|app| root.read(app).engine.read(app).imported_footage().len());
assert_eq!(imported, 1, "a cancelled picker imports nothing");
}
/// Dragging files onto the project explorer routes them to the engine's
/// import: the explorer emits `FileDropRequested` on a real drop and the
/// panel's subscription calls `AppEngine::import_footage` per path (the
/// mock engine records them). The drop is simulated as the platform
/// delivers it — the OS drag entering the window, then the release.
#[gpui::test]
async fn dropping_files_onto_the_project_browser_imports_them(cx: &mut TestAppContext) {
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
let (window, root) = mock_shell(cx);
let mut cx = VisualTestContext::from_window(window.into(), cx);
// The mock shell's parked frame is live: dispatch the drop against it
// (an extra draw would repaint the cached element tree and consume the
// one-shot interactive listeners).
let bounds = cx
.debug_bounds("gpui-widgets-explorer-entry-1")
.expect("the explorer's first row is rendered");
let dropped = PathBuf::from("/media/raw/drag-drop.mov");
let paths = ExternalPaths(std::iter::once(dropped.clone()).collect());
// Deliver the whole drag sequence against the same rendered frame:
// gpui's interactive listeners are registered per render, so a repaint
// between the events would consume them before the drop lands.
cx.update(|window, cx| {
window.dispatch_event(
gpui::PlatformInput::FileDrop(FileDropEvent::Entered {
position: bounds.center(),
paths,
}),
cx,
);
window.dispatch_event(
gpui::PlatformInput::FileDrop(FileDropEvent::Pending {
position: bounds.center(),
}),
cx,
);
window.dispatch_event(
gpui::PlatformInput::FileDrop(FileDropEvent::Submit {
position: bounds.center(),
}),
cx,
);
});
cx.run_until_parked();
let imported = cx.read(|app| root.read(app).engine.read(app).imported_footage().to_vec());
assert_eq!(imported, vec![dropped]);
}
/// Dragging a media entry from the project explorer onto the timeline
/// places a clip there: the row starts a [`FootageDrag`] carrying the
/// entry id, the timeline panel resolves the cursor to a display track +
/// frame, and the engine records the `drop_footage` request with the
/// correct parameters.
#[gpui::test]
async fn dragging_footage_onto_the_timeline_places_a_clip(cx: &mut TestAppContext) {
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
let (window, root) = mock_shell(cx);
let mut cx = VisualTestContext::from_window(window.into(), cx);
// The mock project's root-level footage entry ("第一稿.mp4", id 3) is
// the drag source; the timeline's clip area is the drop target.
let row = cx
.debug_bounds("gpui-widgets-explorer-entry-3")
.expect("root-level footage row rendered");
let canvas = cx
.debug_bounds("timeline-canvas")
.expect("timeline body rendered");
// A point inside the clip area: 100 px into the ruler's content
// (frame 50 at the shell's default zoom of 2 px/frame) on the first
// track row (V2, 64 px tall).
let drop = gpui::point(
canvas.left() + px(gpui::timeline::HEADER_WIDTH + 100.0),
canvas.top() + px(gpui::timeline::RULER_HEIGHT + 20.0),
);
// Dispatch the whole gesture against the same rendered frame: gpui's
// interactive listeners are registered per render, so a repaint
// between the events would consume them before the drop lands (same
// caveat as the file-drop test above).
cx.update(|window, cx| {
window.dispatch_event(
gpui::PlatformInput::MouseDown(gpui::MouseDownEvent {
position: row.center(),
modifiers: gpui::Modifiers::none(),
button: gpui::MouseButton::Left,
click_count: 1,
first_mouse: false,
}),
cx,
);
// Move past the drag threshold to start the drag.
window.dispatch_event(
gpui::PlatformInput::MouseMove(gpui::MouseMoveEvent {
position: row.center() + gpui::point(px(6.0), px(0.0)),
modifiers: gpui::Modifiers::none(),
pressed_button: Some(gpui::MouseButton::Left),
}),
cx,
);
// Hover the drop point (the panel resolves the track + frame).
window.dispatch_event(
gpui::PlatformInput::MouseMove(gpui::MouseMoveEvent {
position: drop,
modifiers: gpui::Modifiers::none(),
pressed_button: Some(gpui::MouseButton::Left),
}),
cx,
);
// Release over the drop point.
window.dispatch_event(
gpui::PlatformInput::MouseUp(gpui::MouseUpEvent {
position: drop,
modifiers: gpui::Modifiers::none(),
button: gpui::MouseButton::Left,
click_count: 1,
}),
cx,
);
});
cx.run_until_parked();
let drops = cx.read(|app| root.read(app).engine.read(app).footage_drops().to_vec());
assert_eq!(drops.len(), 1, "the drop must reach the engine exactly once");
assert_eq!(drops[0].id, 3);
assert_eq!(drops[0].track_kind, gpui::timeline::TrackKind::Video);
assert_eq!(drops[0].track_index, 0);
assert_eq!(drops[0].time, gpui::timeline::Frame(50));
}
/// The project explorer lists root-level imported footage in BOTH views:
/// the tree shows the entry as a row, and after switching to the icon
/// grid the same entry (plus folder children) appears as icons.
#[gpui::test]
async fn explorer_views_list_root_level_footage(cx: &mut TestAppContext) {
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
let (window, _root) = mock_shell(cx);
let mut cx = VisualTestContext::from_window(window.into(), cx);
// Tree view: the root-level footage entry (id 3 "第一稿.mp4") renders
// as a row.
assert!(
cx.debug_bounds("gpui-widgets-explorer-entry-3").is_some(),
"tree view shows the root-level footage row"
);
// Switch to the icon grid; root-level footage and folder children
// both appear as icons, the folder root itself does not.
let toggle = cx
.debug_bounds("gpui-widgets-explorer-icons")
.expect("icons toggle rendered");
cx.simulate_click(toggle.center(), gpui::Modifiers::none());
cx.run_until_parked();
cx.update(|window, cx| {
window.draw(cx).clear();
});
assert!(
cx.debug_bounds("gpui-widgets-explorer-icon-3").is_some(),
"root-level footage shows as an icon"
);
assert!(
cx.debug_bounds("gpui-widgets-explorer-icon-10").is_some(),
"a folder child shows as an icon"
);
assert!(
cx.debug_bounds("gpui-widgets-explorer-icon-1").is_none(),
"the footage folder root itself has no icon"
);
}
/// The command-line parser understands the project path and the mock
/// flag, and the `OAK_ENGINE` env var forces the mock.
#[test]
fn app_args_parse_path_and_mock_flag() {
// Simulate argv without touching the real environment: parse a slice
// directly.
let parse = |argv: &[&str], env: Option<&str>| -> AppArgs {
let mut args = AppArgs::default();
for text in argv {
match *text {
"--mock" => args.mock = true,
other => args.project = Some(PathBuf::from(other)),
}
}
if env.map(|v| v.eq_ignore_ascii_case("mock")).unwrap_or(false) {
args.mock = true;
}
args
};
let a = parse(&["/tmp/a.ove"], None);
assert_eq!(a.project, Some(PathBuf::from("/tmp/a.ove")));
assert!(!a.mock);
let b = parse(&["/tmp/a.ove", "--mock"], None);
assert!(b.mock);
let c = parse(&["/tmp/a.ove"], Some("MOCK"));
assert!(c.mock);
let d = parse(&[], None);
assert!(d.project.is_none());
assert!(!d.mock);
}
// -------------------------------------------------------------------
// Project manager (M13 D4)
// -------------------------------------------------------------------
/// A running app shell on the mock engine (en-US), plus its root. The
/// caller holds the language lock (the tests flip the process-global
/// language).
fn mock_shell(
cx: &mut TestAppContext,
) -> (
gpui::WindowHandle<OakApp<MockEngine>>,
Entity<OakApp<MockEngine>>,
) {
crate::i18n::set_language(crate::i18n::Language::EnUs);
cx.update(|cx| cx.init_colors());
let window = cx.open_window(size(px(1600.0), px(900.0)), |window, cx| {
OakApp::<MockEngine>::new(window, None, cx)
});
cx.run_until_parked();
let root = window.root(cx).expect("app root");
(window, root)
}
/// Opens the manager and returns its content entity.
fn open_manager(
cx: &mut TestAppContext,
root: &Entity<OakApp<MockEngine>>,
) -> Entity<crate::manager::ProjectManager<MockEngine>> {
cx.update(|app| root.update(app, |app, cx| app.show_project_manager(cx)));
cx.run_until_parked();
let content = cx.read(|app| match &root.read(app).modal {
ModalState::Manager { content, .. } => content.clone(),
_ => panic!("the manager modal should be open"),
});
content
}
/// The manager's listed rows.
fn manager_rows(
cx: &mut TestAppContext,
content: &Entity<crate::manager::ProjectManager<MockEngine>>,
) -> Vec<crate::oakui::LibraryProject> {
cx.read(|app| content.read(app).rows().to_vec())
}
/// The manager lists the mock library; opening a row (the double-click
/// / 打开 route) drives the engine's library open and closes the dialog.
#[gpui::test]
async fn manager_lists_and_opens(cx: &mut TestAppContext) {
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
let (_window, root) = mock_shell(cx);
let content = open_manager(cx, &root);
let rows = manager_rows(cx, &content);
assert_eq!(rows.len(), 3, "the mock library seeds three rows");
assert_eq!(rows[0].name, "第一稿", "most recently modified first");
assert!(rows[0].track_count > 0 && rows[0].footage_count > 0);
// Select the second row and open it.
let uuid = rows[1].uuid.clone();
cx.update(|app| content.update(app, |m, cx| m.select(&uuid, cx)));
cx.update(|app| {
root.update(app, |app, cx| {
app.on_manager_event(&crate::manager::ManagerEvent::Open(uuid.clone()), cx)
})
});
cx.run_until_parked();
let opened = cx.read(|app| root.read(app).engine.read(app).library_opened().to_vec());
assert_eq!(opened, vec![uuid], "the engine opened the selected row");
let name = cx.read(|app| root.read(app).engine.read(app).project().unwrap().name.clone());
assert_eq!(name, "宣传片 v3");
let modal_none = cx.read(|app| matches!(root.read(app).modal, ModalState::None));
assert!(modal_none, "a successful open closes the manager");
}
/// Create / rename / duplicate / delete round-trip through the manager
/// and its sub-dialogs.
#[gpui::test]
async fn manager_create_rename_duplicate_delete(cx: &mut TestAppContext) {
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
let (_window, root) = mock_shell(cx);
let content = open_manager(cx, &root);
// Create: a new row appears and the project opens (dialog closes).
cx.update(|app| {
root.update(app, |app, cx| {
app.on_manager_event(&crate::manager::ManagerEvent::Create, cx)
})
});
cx.run_until_parked();
let rows = cx.read(|app| root.read(app).engine.read(app).library_projects().unwrap());
assert_eq!(rows.len(), 4);
let created = rows
.iter()
.find(|row| row.name == "Untitled Project")
.expect("the created row")
.clone();
let project_name = cx.read(|app| root.read(app).engine.read(app).project().unwrap().name.clone());
assert_eq!(project_name, "Untitled Project", "create opens the new project");
// Reopen the manager, select the created row, rename it.
let content = open_manager(cx, &root);
cx.update(|app| content.update(app, |m, cx| m.select(&created.uuid, cx)));
cx.update(|app| {
root.update(app, |app, cx| {
app.on_manager_event(&crate::manager::ManagerEvent::Rename(created.uuid.clone()), cx)
})
});
cx.run_until_parked();
let prompt = cx.read(|app| match &root.read(app).modal {
ModalState::ManagerRename { content, uuid, .. } => {
assert_eq!(uuid, &created.uuid);
content.clone()
}
_ => panic!("the rename prompt should be open"),
});
cx.update(|app| prompt.update(app, |p, cx| p.set_value("改名为正稿", cx)));
cx.update(|app| {
root.update(app, |app, cx| {
app.on_modal(
&ModalEvent::ButtonClicked {
control: modal_ids::MANAGER_RENAME,
button: 0,
},
cx,
)
})
});
cx.run_until_parked();
// The confirmation swaps in a FRESH manager (the captured content is
// stale from here on); assert against the engine's library instead.
let rows = cx.read(|app| root.read(app).engine.read(app).library_projects().unwrap());
let renamed = rows.iter().find(|row| row.uuid == created.uuid).unwrap();
assert_eq!(renamed.name, "改名为正稿");
let back = cx.read(|app| matches!(root.read(app).modal, ModalState::Manager { .. }));
assert!(back, "a confirmed rename returns to the manager");
// Duplicate the renamed row.
cx.update(|app| {
root.update(app, |app, cx| {
app.on_manager_event(
&crate::manager::ManagerEvent::Duplicate(created.uuid.clone()),
cx,
)
})
});
cx.run_until_parked();
let rows = cx.read(|app| root.read(app).engine.read(app).library_projects().unwrap());
assert_eq!(rows.len(), 5);
assert!(
rows.iter().any(|row| row.name == "改名为正稿 (copy)"),
"the copy is named '<name> (copy)': {rows:?}"
);
// Delete the original through the confirmation dialog.
cx.update(|app| {
root.update(app, |app, cx| {
app.on_manager_event(&crate::manager::ManagerEvent::Delete(created.uuid.clone()), cx)
})
});
cx.run_until_parked();
let confirming = cx.read(|app| matches!(root.read(app).modal, ModalState::ManagerDelete { .. }));
assert!(confirming, "the delete confirmation should be open");
cx.update(|app| {
root.update(app, |app, cx| {
app.on_modal(
&ModalEvent::ButtonClicked {
control: modal_ids::MANAGER_DELETE,
button: 0,
},
cx,
)
})
});
cx.run_until_parked();
let rows = cx.read(|app| root.read(app).engine.read(app).library_projects().unwrap());
assert_eq!(rows.len(), 4);
assert!(!rows.iter().any(|row| row.uuid == created.uuid));
let back = cx.read(|app| matches!(root.read(app).modal, ModalState::Manager { .. }));
assert!(back, "a confirmed delete returns to the manager");
}
/// The manager's 导入 opens the platform path picker and lands the file
/// as a new row; 导出 asks for a new path and routes it to the engine.
#[gpui::test]
async fn manager_import_and_export_route_through_the_platform_dialogs(
cx: &mut TestAppContext,
) {
let _guard = crate::i18n::lang_test_lock().lock().unwrap();
let (_window, root) = mock_shell(cx);
let content = open_manager(cx, &root);
// Import.
cx.update(|app| {
root.update(app, |app, cx| {
app.on_manager_event(&crate::manager::ManagerEvent::Import, cx)
})
});
cx.run_until_parked();
assert!(cx.did_prompt_for_paths(), "import shows the path picker");
cx.simulate_path_prompt_response(|options| {
assert!(!options.multiple, "project import is single-file");
Some(vec![PathBuf::from("/library/先导片.ove")])
});
cx.run_until_parked();
let rows = manager_rows(cx, &content);
assert_eq!(rows.len(), 4);
assert!(
rows.iter().any(|row| row.name == "先导片"),
"the imported file becomes a row named by its stem: {rows:?}"
);
// Export the imported row.
let uuid = rows
.iter()
.find(|row| row.name == "先导片")
.unwrap()
.uuid
.clone();
cx.update(|app| content.update(app, |m, cx| m.select(&uuid, cx)));
cx.update(|app| {
root.update(app, |app, cx| {
app.on_manager_event(&crate::manager::ManagerEvent::Export(uuid.clone()), cx)
})
});
cx.run_until_parked();
assert!(
cx.did_prompt_for_new_path(),
"export shows the save dialog"
);
cx.simulate_new_path_selection(|_dir| Some(PathBuf::from("/library/先导片.otio")));
cx.run_until_parked();
let exported = cx.read(|app| root.read(app).engine.read(app).library_exported().to_vec());
assert_eq!(
exported,
vec![(uuid, PathBuf::from("/library/先导片.otio"))],
"the picked path routes to the engine's library export"
);
}
}