) {
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