) {
- use menu_ids::*;
- match item {
+ 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)
+ }),
+ _ => 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 ------------------------------------------------------
- NEW_PROJECT => self.new_project(cx),
- OPEN_PROJECT => self.open_file_dialog(FileAction::Open, cx),
- OPEN_FROM_LIBRARY | PROJECT_MANAGER => self.show_project_manager(cx),
- IMPORT_FOOTAGE => self.open_file_dialog(FileAction::ImportFootage, cx),
- EXPORT_PROJECT => self.open_file_dialog(FileAction::ExportProjectFile, cx),
- CLOSE => self
+ 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)),
- EXPORT => self.open_export_dialog(cx),
- QUIT => cx.quit(),
+ A::Export => self.open_export_dialog(cx),
+ A::Exit => cx.quit(),
// --- Edit ------------------------------------------------------
- UNDO => self.engine.update(cx, |engine, cx| engine.undo(cx)),
- REDO => self.engine.update(cx, |engine, cx| engine.redo(cx)),
- DELETE => self.delete_timeline_selection(false, cx),
- RIPPLE_DELETE => self.delete_timeline_selection(true, cx),
- SELECT_ALL => self.select_all_clips(cx),
- CUT | COPY | PASTE => {
- println!("[menu] clipboard action {item} not wired yet");
- }
+ 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 ------------------------------------------------------
- THEME_DARK => {
+ A::ThemeDark => {
crate::oakui::real::set_theme_dark(true);
self.apply_dark(true, cx);
}
- THEME_LIGHT => {
+ A::ThemeLight => {
crate::oakui::real::set_theme_dark(false);
self.apply_dark(false, cx);
}
- ZOOM_IN => self.zoom_timeline(1.25, cx),
- ZOOM_OUT => self.zoom_timeline(0.8, cx),
- LANG_ZH => self.switch_language(crate::i18n::Language::ZhCN, cx),
- LANG_EN => self.switch_language(crate::i18n::Language::EnUs, cx),
- PREFERENCES => self.open_preferences(cx),
- // --- Playback --------------------------------------------------
- PLAY_PAUSE => {
+ 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| {
@@ -623,69 +790,124 @@ impl OakApp {
}
});
}
- PREV_FRAME => {
+ A::PrevFrame => {
let monitor = Monitor::Program;
self.engine
.update(cx, |engine, cx| engine.step(monitor, -1, cx));
}
- NEXT_FRAME => {
+ A::NextFrame => {
let monitor = Monitor::Program;
self.engine
.update(cx, |engine, cx| engine.step(monitor, 1, cx));
}
- PLAY => {
+ 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.play(monitor, cx));
+ .update(cx, |engine, cx| engine.step(monitor, -1, cx));
}
- PAUSE => {
+ A::ShuttleStop => {
let monitor = Monitor::Program;
self.engine
.update(cx, |engine, cx| engine.pause(monitor, cx));
}
- TO_START => {
+ 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)
});
}
- SET_IN_POINT => self.set_point_at_playhead(true, cx),
- SET_OUT_POINT => self.set_point_at_playhead(false, 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 --------------------------------------------------
- ADD_VIDEO_TRACK => {
+ A::AddVideoTrack => {
let kind = gpui::timeline::TrackKind::Video;
self.engine
.update(cx, |engine, cx| engine.add_track(kind, cx));
}
- ADD_AUDIO_TRACK => {
+ A::AddAudioTrack => {
let kind = gpui::timeline::TrackKind::Audio;
self.engine
.update(cx, |engine, cx| engine.add_track(kind, cx));
}
- REMOVE_TRACK => self.remove_selected_track(cx),
- SPLIT_AT_PLAYHEAD => self
- .engine
- .update(cx, |engine, cx| engine.split_at_playhead(cx)),
- ADD_MARKER => self
- .engine
- .update(cx, |engine, cx| engine.add_marker_at_playhead(cx)),
- REMOVE_MARKER => self
+ A::RemoveTrack => self.remove_selected_track(cx),
+ A::RemoveMarker => self
.engine
.update(cx, |engine, cx| engine.remove_marker_at_playhead(cx)),
- SET_WORKAREA => self.set_workarea_from_selection(cx),
- CLEAR_WORKAREA => self
- .engine
- .update(cx, |engine, cx| engine.clear_workarea(cx)),
+ A::SetWorkArea => self.set_workarea_from_selection(cx),
// --- Window ----------------------------------------------------
- FOCUS_PROJECT => self.focus_panel(PROJECT, cx),
- FOCUS_SOURCE_VIEWER => self.focus_panel(SOURCE_VIEWER, cx),
- FOCUS_PROGRAM_VIEWER => self.focus_panel(PROGRAM_VIEWER, cx),
- FOCUS_NODE_EDITOR => self.focus_panel(NODE_EDITOR, cx),
- FOCUS_INSPECTOR => self.focus_panel(INSPECTOR, cx),
- FOCUS_HISTORY => self.focus_panel(HISTORY, cx),
- FOCUS_TIMELINE => self.focus_panel(TIMELINE, cx),
- FOCUS_EFFECT_LIBRARY => self.focus_panel(EFFECT_LIBRARY, cx),
- other => println!("[menu] placeholder action for item {other}"),
+ 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),
+ // --- 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);
+ }
+ // --- everything else is a placeholder --------------------------
+ other => println!(
+ "[action] {} not wired yet (placeholder)",
+ other.entry().cpp_id
+ ),
}
}
@@ -740,6 +962,17 @@ impl OakApp {
.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) {
@@ -750,6 +983,20 @@ impl OakApp {
});
}
+ /// 视图 → 轨道高度 ±: 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
@@ -795,19 +1042,16 @@ impl OakApp {
cx.notify();
}
- /// The shell's keyboard shortcut entry point: maps the keystroke through
- /// [`crate::shortcuts`] and dispatches the matched menu action. While a
- /// modal dialog is open the shell stays keyboard-quiet, so the dialogs'
- /// text fields never trigger editing actions.
- fn on_shortcut(&mut self, keystroke: &gpui::Keystroke, cx: &mut Context) {
+ /// 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;
}
- let Some(action) = crate::shortcuts::action_for(keystroke) else {
- return;
- };
- cx.stop_propagation();
- self.on_menu(action, cx);
+ self.dispatch_action_id(action, cx);
}
/// Removes the first track selected in the timeline header.
@@ -883,15 +1127,22 @@ impl OakApp {
}
/// Replaces the `MenuBar` entity with one built from the current language
- /// and theme, re-subscribing to its trigger events.
+ /// 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 dark = self.dark;
+ 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,
+ };
let Ok(menu_bar) = cx.update_window(*handle, |_root, window, app| {
- app.new(|cx| MenuBar::new(1, make_menus(dark), window, cx))
+ app.new(|cx| MenuBar::new(1, make_menus(state), window, cx))
}) else {
return;
};
@@ -1550,13 +1801,23 @@ impl Render for OakApp {
.size_full()
.flex()
.flex_col()
- .track_focus(&self.shell_focus)
- // The shell's keyboard shortcut layer: keys not consumed by a
- // focused widget bubble up here and dispatch through the
- // shortcut table (see `crate::shortcuts`).
- .on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, _window, cx| {
- this.on_shortcut(&event.keystroke, cx);
- }))
+ .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());
@@ -1567,138 +1828,251 @@ impl Render for OakApp {
}
}
-/// Builds the menu bar entries (文件/编辑/视图/回放/序列/窗口/工具/帮助). All
-/// labels come from the [`crate::i18n`] tables, so rebuilding the menu bar
-/// after a language switch repaints it in the new language. `dark` drives the
-/// theme submenu's checkmark.
-fn make_menus(dark: bool) -> Vec {
- use crate::i18n::tr;
- use menu_ids::*;
+/// 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,
+}
- let current = crate::i18n::language();
+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,
+ }
+ }
+}
+
+/// 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![
- MenuItem::new(THEME_DARK, tr("menu.view.theme.dark")).with_checked(dark),
- MenuItem::new(THEME_LIGHT, tr("menu.view.theme.light")).with_checked(!dark),
+ 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![
- MenuItem::new(LANG_ZH, tr("menu.view.language.zh"))
- .with_checked(current == crate::i18n::Language::ZhCN),
- MenuItem::new(LANG_EN, tr("menu.view.language.en"))
- .with_checked(current == crate::i18n::Language::EnUs),
+ 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