// 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 . //! 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 { None, Preferences { modal: Entity, content: Entity, }, Export { modal: Entity, content: Entity, }, Progress { modal: Entity, content: Entity, }, /// The project manager (M13 D4). Manager { modal: Entity, content: Entity>, }, /// The manager's rename prompt. ManagerRename { modal: Entity, content: Entity, uuid: String, }, /// The manager's delete confirmation. ManagerDelete { modal: Entity, uuid: String }, /// The proxy settings dialog (Tools > Proxy Settings). Proxy { modal: Entity, content: Entity>, }, } /// A running export: the session the tick loop drains for progress. struct ExportRun { session: ExportSession, } impl ModalState { /// The modal entity currently shown, if any. fn modal_entity(&self) -> Option> { 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 { engine: Entity, source_clock: Entity, program_clock: Entity, } impl PanelRegistry for AppPanelRegistry { fn panel_key(&self, id: gpui::dock::PanelId) -> Option { 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 { 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 { engine: Entity, program_clock: Entity, timeline: Entity>, meter: Entity>, menu_bar: Entity, dock: Entity, status_bar: Entity>, /// Whether the dark theme is active (toggles via 视图 → 主题). dark: bool, /// The modal currently shown on top of the shell, if any. modal: ModalState, /// 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, /// The library row pending an export save dialog (manager 导出). pending_export: Option, /// 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, /// 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, /// 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 { project: Entity>, source_viewer: Entity>, program_viewer: Entity>, node_editor: Entity>, inspector: Entity>, history: Entity>, timeline: Entity>, effect_library: Entity>, multicam: Entity>, } impl OakApp { /// 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, cx: &mut Context) -> 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::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, 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 = 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.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) { 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

(cx: &mut Context, panel: &Entity

, id: PanelId) where P: gpui::EventEmitter, { 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) { 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) -> 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) { 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) { 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) { let ids: Vec = 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) { let ids: Vec = { 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.timeline.update(cx, |view, cx| { view.state.select_range(std::iter::empty::()); 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.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 (24–160px). fn nudge_track_height(&mut self, delta: f32, cx: &mut Context) { 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) { 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.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) { 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) { 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) { 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 = self.timeline.read(cx).selection().iter().copied().collect(); let engine = self.engine.read(cx); let mut start: Option = 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) { 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) { 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) { 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, 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) { 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) { 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) { 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) { 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 { 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) { 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) { 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) { 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) { 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.modal = ModalState::None; self.show_project_manager(cx); } /// Opens the platform save dialog for exporting the library row `uuid` /// (the suggested name is `.ove`; the format follows the /// extension the user picks). fn open_manager_export(&mut self, uuid: String, cx: &mut Context) { 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.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, build: impl FnOnce(&mut Window, &mut App) -> ModalState, ) { 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) { 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, cx: &mut Context) { // 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.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) { 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) { 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) { 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) { 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) { 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) { 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) { 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 Render for OakApp { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> 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 { 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 = 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 { 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, /// 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( window: &mut Window, initial: Option, cx: &mut App, ) -> Entity> { 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::(args.clone()); } else { run_with::(args); } } /// Runs the app window with `E` as the engine backend. fn run_with(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::(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::::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) { 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)> { 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 (24–160px 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::::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>, Entity>, ) { 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::::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>, ) -> Entity> { 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>, ) -> Vec { 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 ' (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" ); } }