diff --git a/gpui b/gpui index 6f75d3c92..050d5ba22 160000 --- a/gpui +++ b/gpui @@ -1 +1 @@ -Subproject commit 6f75d3c92a93cdda10d743d52fd0f10378ce574a +Subproject commit 050d5ba22de8b541dfd42111bb063c1ce757cd7d diff --git a/src/actions.rs b/src/actions.rs new file mode 100644 index 000000000..3c8908b5a --- /dev/null +++ b/src/actions.rs @@ -0,0 +1,558 @@ +// 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 action registry: the single data source behind the menu bar, the +//! keyboard shortcuts and (later) the shortcut preferences page and the +//! action search dialog — the Rust counterpart of the C++ `MainMenu` + +//! `MenuShared` action system (`app/window/mainwindow/mainmenu.cpp`, +//! `app/widget/menu/menushared.cpp`). +//! +//! Every entry pairs a gpui action (defined through the [`gpui::actions`] +//! macro) with the C++ action's stable id string (`newproj`, `rippledelete`, +//! `snapping`, … — kept for future shortcut-file compatibility), its i18n +//! key, its default key(s) in gpui keystroke syntax (`secondary-` is the +//! platform command key) and a routing target: +//! +//! * [`Route::Global`] — the app shell handles it (file dialogs, undo, +//! preferences, tool selection, …); +//! * [`Route::FocusedPanel`] — the command goes to the currently focused +//! panel through [`crate::panels::commands::PanelCommandHandler`] first, +//! falling back to the shell's global handler when the panel does not +//! implement it (the C++ `PanelManager::currently_focused()` pattern). +//! +//! Menu clicks and key presses dispatch through the same path: the menu bar +//! reports the item id, the keymap dispatches the gpui action, and both end +//! up in `OakApp::dispatch_action_id`. + +use gpui::{Action, KeyBinding}; + +// One macro call generates all three views of the registry, so they can +// never drift apart: the gpui action structs, the `ActionId` enum and the +// `REGISTRY` table. Menu ids reuse the pre-action-system values for the +// actions that existed before (the tests' `menu_ids` constants). +macro_rules! define_actions { + ($( + $name:ident { + cpp: $cpp:literal, + i18n: $i18n:literal, + keys: [$($key:literal),*], + route: $route:ident, + menu_id: $menu_id:expr + } + );* ;) => { + gpui::actions!(oak, [ $($name),* ]); + + /// The stable identity of every app action (registry index). + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub enum ActionId { + $($name),* + } + + impl ActionId { + /// The menu item id this action dispatches from (the menu bar's + /// numeric item ids; unique across the whole menu tree). + pub const fn menu_id(self) -> usize { + match self { + $( ActionId::$name => $menu_id ),* + } + } + + /// This action's registry entry. + pub fn entry(self) -> &'static ActionEntry { + REGISTRY + .iter() + .find(|entry| entry.action == self) + .expect("every ActionId is in the registry") + } + } + + /// The action registry, in menu order. + pub const REGISTRY: &[ActionEntry] = &[ + $( ActionEntry { + action: ActionId::$name, + cpp_id: $cpp, + i18n_key: $i18n, + default_keys: &[$($key),*], + route: Route::$route, + build: || Box::new($name), + } ),* + ]; + }; +} + +define_actions! { + // --- File -------------------------------------------------------------- + NewProject { cpp: "newproj", i18n: "menu.file.new_project", keys: ["secondary-n"], route: Global, menu_id: 101 }; + NewSequence { cpp: "newseq", i18n: "menu.file.new_sequence", keys: ["secondary-shift-n"], route: Global, menu_id: 1001 }; + NewFolder { cpp: "newfolder", i18n: "menu.file.new_folder", keys: [], route: Global, menu_id: 1002 }; + OpenProject { cpp: "openproj", i18n: "menu.file.open_project", keys: ["secondary-o"], route: Global, menu_id: 102 }; + OpenFromLibrary { cpp: "openlibrary", i18n: "menu.file.open_library", keys: [], route: Global, menu_id: 110 }; + ClearOpenRecent { cpp: "clearopenrecent", i18n: "menu.file.clear_recent", keys: [], route: Global, menu_id: 1003 }; + SaveProject { cpp: "saveproj", i18n: "menu.file.export_project", keys: ["secondary-s"], route: Global, menu_id: 103 }; + SaveProjectAs { cpp: "saveprojas", i18n: "menu.file.save_as", keys: ["secondary-shift-s"], route: Global, menu_id: 1004 }; + Revert { cpp: "revert", i18n: "menu.file.revert", keys: ["f12"], route: Global, menu_id: 1005 }; + Import { cpp: "import", i18n: "menu.file.import_footage", keys: ["secondary-i"], route: Global, menu_id: 108 }; + Export { cpp: "export", i18n: "menu.file.export_media", keys: ["secondary-m"], route: Global, menu_id: 106 }; + ProjectProperties { cpp: "projectproperties", i18n: "menu.file.project_properties", keys: ["shift-f10"], route: Global, menu_id: 1006 }; + CloseProject { cpp: "closeproj", i18n: "menu.file.close", keys: [], route: Global, menu_id: 105 }; + ProjectManager { cpp: "projectmanager", i18n: "menu.file.project_manager", keys: [], route: Global, menu_id: 109 }; + Exit { cpp: "exit", i18n: "menu.file.quit", keys: ["secondary-q"], route: Global, menu_id: 107 }; + + // --- Edit --------------------------------------------------------------- + Undo { cpp: "undo", i18n: "menu.edit.undo", keys: ["secondary-z"], route: Global, menu_id: 201 }; + Redo { cpp: "redo", i18n: "menu.edit.redo", keys: ["secondary-shift-z"], route: Global, menu_id: 202 }; + Cut { cpp: "cut", i18n: "menu.edit.cut", keys: ["secondary-x"], route: FocusedPanel, menu_id: 203 }; + Copy { cpp: "copy", i18n: "menu.edit.copy", keys: ["secondary-c"], route: FocusedPanel, menu_id: 204 }; + Paste { cpp: "paste", i18n: "menu.edit.paste", keys: ["secondary-v"], route: FocusedPanel, menu_id: 205 }; + PasteInsert { cpp: "pasteinsert", i18n: "menu.edit.paste_insert", keys: ["secondary-shift-v"], route: FocusedPanel, menu_id: 1010 }; + Duplicate { cpp: "duplicate", i18n: "menu.edit.duplicate", keys: ["secondary-d"], route: FocusedPanel, menu_id: 1011 }; + Rename { cpp: "rename", i18n: "menu.edit.rename", keys: ["f2"], route: FocusedPanel, menu_id: 1012 }; + Delete { cpp: "delete", i18n: "menu.edit.delete", keys: ["delete", "backspace"], route: FocusedPanel, menu_id: 206 }; + RippleDelete { cpp: "rippledelete", i18n: "menu.edit.ripple_delete", keys: ["shift-delete", "shift-backspace"], route: FocusedPanel, menu_id: 207 }; + SplitAtPlayhead { cpp: "split", i18n: "menu.edit.split", keys: ["secondary-k"], route: FocusedPanel, menu_id: 504 }; + SpeedDuration { cpp: "speeddur", i18n: "menu.edit.speed_duration", keys: ["secondary-r"], route: FocusedPanel, menu_id: 1014 }; + DefaultTransition { cpp: "deftransition", i18n: "menu.edit.default_transition", keys: ["secondary-shift-d"], route: FocusedPanel, menu_id: 1015 }; + LinkUnlink { cpp: "linkunlink", i18n: "menu.edit.link_unlink", keys: ["secondary-l"], route: FocusedPanel, menu_id: 1016 }; + EnableDisable { cpp: "enabledisable", i18n: "menu.edit.enable_disable", keys: ["shift-e"], route: FocusedPanel, menu_id: 1017 }; + Nest { cpp: "nest", i18n: "menu.edit.nest", keys: [], route: FocusedPanel, menu_id: 1018 }; + SelectAll { cpp: "selectall", i18n: "menu.edit.select_all", keys: ["secondary-a"], route: FocusedPanel, menu_id: 208 }; + DeselectAll { cpp: "deselectall", i18n: "menu.edit.deselect_all", keys: ["secondary-shift-a"], route: FocusedPanel, menu_id: 1019 }; + Insert { cpp: "insert", i18n: "menu.edit.insert", keys: [","], route: FocusedPanel, menu_id: 1020 }; + Overwrite { cpp: "overwrite", i18n: "menu.edit.overwrite", keys: ["."], route: FocusedPanel, menu_id: 1021 }; + RippleToIn { cpp: "rippletoin", i18n: "menu.edit.ripple_to_in", keys: ["q"], route: FocusedPanel, menu_id: 1022 }; + RippleToOut { cpp: "rippletoout", i18n: "menu.edit.ripple_to_out", keys: ["w"], route: FocusedPanel, menu_id: 1023 }; + EditToIn { cpp: "edittoin", i18n: "menu.edit.edit_to_in", keys: ["secondary-alt-q"], route: FocusedPanel, menu_id: 1024 }; + EditToOut { cpp: "edittoout", i18n: "menu.edit.edit_to_out", keys: ["secondary-alt-w"], route: FocusedPanel, menu_id: 1025 }; + NudgeLeft { cpp: "nudgeleft", i18n: "menu.edit.nudge_left", keys: ["alt-left"], route: FocusedPanel, menu_id: 1026 }; + NudgeRight { cpp: "nudgeright", i18n: "menu.edit.nudge_right", keys: ["alt-right"], route: FocusedPanel, menu_id: 1027 }; + MoveInToPlayhead { cpp: "moveintoplayhead", i18n: "menu.edit.move_in_to_playhead", keys: ["["], route: FocusedPanel, menu_id: 1028 }; + MoveOutToPlayhead { cpp: "moveouttoplayhead", i18n: "menu.edit.move_out_to_playhead", keys: ["]"], route: FocusedPanel, menu_id: 1029 }; + SetInPoint { cpp: "setinpoint", i18n: "menu.edit.set_in_point", keys: ["i"], route: FocusedPanel, menu_id: 407 }; + SetOutPoint { cpp: "setoutpoint", i18n: "menu.edit.set_out_point", keys: ["o"], route: FocusedPanel, menu_id: 408 }; + ResetIn { cpp: "resetin", i18n: "menu.edit.reset_in", keys: [], route: FocusedPanel, menu_id: 1030 }; + ResetOut { cpp: "resetout", i18n: "menu.edit.reset_out", keys: [], route: FocusedPanel, menu_id: 1031 }; + ClearInOut { cpp: "clearinout", i18n: "menu.edit.clear_in_out", keys: ["g"], route: FocusedPanel, menu_id: 1032 }; + DeleteInOut { cpp: "deleteinout", i18n: "menu.edit.delete_in_out", keys: [";"], route: FocusedPanel, menu_id: 1033 }; + RippleDeleteInOut { cpp: "rippledeleteinout", i18n: "menu.edit.ripple_delete_in_out", keys: ["'"], route: FocusedPanel, menu_id: 1034 }; + Marker { cpp: "marker", i18n: "menu.edit.marker", keys: ["m"], route: FocusedPanel, menu_id: 505 }; + + // --- View --------------------------------------------------------------- + ZoomIn { cpp: "zoomin", i18n: "menu.view.zoom_in", keys: ["=", "shift-="], route: FocusedPanel, menu_id: 306 }; + ZoomOut { cpp: "zoomout", i18n: "menu.view.zoom_out", keys: ["-"], route: FocusedPanel, menu_id: 307 }; + IncreaseTrackHeight { cpp: "vzoomin", i18n: "menu.view.increase_track_height", keys: ["secondary-="], route: FocusedPanel, menu_id: 1040 }; + DecreaseTrackHeight { cpp: "vzoomout", i18n: "menu.view.decrease_track_height", keys: ["secondary--"], route: FocusedPanel, menu_id: 1041 }; + ToggleShowAll { cpp: "showall", i18n: "menu.view.show_all", keys: ["\\"], route: FocusedPanel, menu_id: 1042 }; + FullScreen { cpp: "fullscreen", i18n: "menu.view.full_screen", keys: ["f11"], route: Global, menu_id: 1043 }; + FullScreenViewer { cpp: "fullscreenviewer", i18n: "menu.view.full_screen_viewer", keys: [], route: FocusedPanel, menu_id: 1044 }; + ThemeDark { cpp: "themedark", i18n: "menu.view.theme.dark", keys: [], route: Global, menu_id: 301 }; + ThemeLight { cpp: "themelight", i18n: "menu.view.theme.light", keys: [], route: Global, menu_id: 302 }; + LangZh { cpp: "langzh", i18n: "menu.view.language.zh", keys: [], route: Global, menu_id: 303 }; + LangEn { cpp: "langen", i18n: "menu.view.language.en", keys: [], route: Global, menu_id: 304 }; + + // --- Playback ----------------------------------------------------------- + GoToStart { cpp: "gotostart", i18n: "menu.playback.to_start", keys: ["home"], route: FocusedPanel, menu_id: 404 }; + PrevFrame { cpp: "prevframe", i18n: "menu.playback.prev_frame", keys: ["left"], route: FocusedPanel, menu_id: 402 }; + PlayPause { cpp: "playpause", i18n: "menu.playback.play_pause", keys: ["space"], route: FocusedPanel, menu_id: 401 }; + PlayInToOut { cpp: "playintoout", i18n: "menu.playback.play_in_to_out", keys: ["shift-space"], route: FocusedPanel, menu_id: 1050 }; + NextFrame { cpp: "nextframe", i18n: "menu.playback.next_frame", keys: ["right"], route: FocusedPanel, menu_id: 403 }; + GoToEnd { cpp: "gotoend", i18n: "menu.playback.go_to_end", keys: ["end"], route: FocusedPanel, menu_id: 1051 }; + GoToPrevCut { cpp: "prevcut", i18n: "menu.playback.prev_cut", keys: ["up"], route: FocusedPanel, menu_id: 1052 }; + GoToNextCut { cpp: "nextcut", i18n: "menu.playback.next_cut", keys: ["down"], route: FocusedPanel, menu_id: 1053 }; + GoToIn { cpp: "gotoin", i18n: "menu.playback.go_to_in", keys: ["shift-i"], route: FocusedPanel, menu_id: 1054 }; + GoToOut { cpp: "gotoout", i18n: "menu.playback.go_to_out", keys: ["shift-o"], route: FocusedPanel, menu_id: 1055 }; + ShuttleLeft { cpp: "decspeed", i18n: "menu.playback.shuttle_left", keys: ["j"], route: FocusedPanel, menu_id: 1056 }; + ShuttleStop { cpp: "pause", i18n: "menu.playback.shuttle_stop", keys: ["k"], route: FocusedPanel, menu_id: 406 }; + ShuttleRight { cpp: "incspeed", i18n: "menu.playback.shuttle_right", keys: ["l"], route: FocusedPanel, menu_id: 405 }; + Loop { cpp: "loop", i18n: "menu.playback.loop", keys: [], route: Global, menu_id: 1057 }; + + // --- Sequence ----------------------------------------------------------- + AddVideoTrack { cpp: "addvideotrack", i18n: "menu.sequence.add_video_track", keys: [], route: Global, menu_id: 501 }; + AddAudioTrack { cpp: "addaudiotrack", i18n: "menu.sequence.add_audio_track", keys: [], route: Global, menu_id: 502 }; + RemoveTrack { cpp: "removetrack", i18n: "menu.sequence.remove_track", keys: [], route: Global, menu_id: 503 }; + SetWorkArea { cpp: "setworkarea", i18n: "menu.sequence.set_workarea", keys: [], route: Global, menu_id: 507 }; + ClearWorkArea { cpp: "clearworkarea", i18n: "menu.sequence.clear_workarea", keys: [], route: Global, menu_id: 508 }; + RemoveMarker { cpp: "removemarker", i18n: "menu.sequence.remove_marker", keys: [], route: Global, menu_id: 506 }; + SeqCache { cpp: "seqcache", i18n: "menu.sequence.cache", keys: [], route: Global, menu_id: 1060 }; + SeqCacheInOut { cpp: "seqcacheinout", i18n: "menu.sequence.cache_in_out", keys: [], route: Global, menu_id: 1061 }; + SeqCacheClear { cpp: "seqcacheclear", i18n: "menu.sequence.cache_clear", keys: [], route: Global, menu_id: 1062 }; + SequenceSettings { cpp: "seqsettings", i18n: "menu.sequence.settings", keys: [], route: Global, menu_id: 704 }; + + // --- Window ------------------------------------------------------------- + FocusProject { cpp: "focusproject", i18n: "menu.window.project", keys: [], route: Global, menu_id: 601 }; + FocusSourceViewer { cpp: "focussourceviewer", i18n: "menu.window.source_viewer", keys: [], route: Global, menu_id: 602 }; + FocusProgramViewer { cpp: "focusprogramviewer", i18n: "menu.window.program_viewer", keys: [], route: Global, menu_id: 603 }; + FocusNodeEditor { cpp: "focusnodeeditor", i18n: "menu.window.node_editor", keys: [], route: Global, menu_id: 604 }; + FocusInspector { cpp: "focusinspector", i18n: "menu.window.inspector", keys: [], route: Global, menu_id: 605 }; + FocusHistory { cpp: "focushistory", i18n: "menu.window.history", keys: [], route: Global, menu_id: 606 }; + FocusTimeline { cpp: "focustimeline", i18n: "menu.window.timeline", keys: [], route: Global, menu_id: 607 }; + FocusEffectLibrary { cpp: "focuseffectlibrary", i18n: "menu.window.effect_library", keys: [], route: Global, menu_id: 608 }; + MaximizePanel { cpp: "maximizepanel", i18n: "menu.window.maximize_panel", keys: ["`"], route: Global, menu_id: 1070 }; + ResetDefaultLayout { cpp: "resetdefaultlayout", i18n: "menu.window.reset_layout", keys: [], route: Global, menu_id: 1071 }; + + // --- Tools (the mutually exclusive tool group + snapping + proxy) ------- + PointerTool { cpp: "pointertool", i18n: "menu.tools.pointer", keys: ["v"], route: Global, menu_id: 1080 }; + TrackSelectTool { cpp: "trackselecttool", i18n: "menu.tools.track_select", keys: ["d"], route: Global, menu_id: 1081 }; + EditTool { cpp: "edittool", i18n: "menu.tools.edit", keys: ["x"], route: Global, menu_id: 1082 }; + RippleTool { cpp: "rippletool", i18n: "menu.tools.ripple", keys: ["b"], route: Global, menu_id: 1083 }; + RollingTool { cpp: "rollingtool", i18n: "menu.tools.rolling", keys: ["n"], route: Global, menu_id: 1084 }; + RazorTool { cpp: "razortool", i18n: "menu.tools.razor_tool", keys: ["c"], route: Global, menu_id: 1085 }; + SlipTool { cpp: "sliptool", i18n: "menu.tools.slip", keys: ["y"], route: Global, menu_id: 1086 }; + SlideTool { cpp: "slidetool", i18n: "menu.tools.slide", keys: ["u"], route: Global, menu_id: 1087 }; + HandTool { cpp: "handtool", i18n: "menu.tools.hand", keys: ["h"], route: Global, menu_id: 1088 }; + ZoomTool { cpp: "zoomtool", i18n: "menu.tools.zoom_tool", keys: ["z"], route: Global, menu_id: 1089 }; + TransitionTool { cpp: "transitiontool", i18n: "menu.tools.transition", keys: ["t"], route: Global, menu_id: 1090 }; + AddTool { cpp: "addtool", i18n: "menu.tools.add", keys: ["a"], route: Global, menu_id: 1091 }; + RecordTool { cpp: "recordtool", i18n: "menu.tools.record", keys: ["r"], route: Global, menu_id: 1092 }; + AddEmpty { cpp: "add:empty", i18n: "menu.tools.addable.empty", keys: [], route: Global, menu_id: 1100 }; + AddBars { cpp: "add:bars", i18n: "menu.tools.addable.bars", keys: [], route: Global, menu_id: 1101 }; + AddShape { cpp: "add:shape", i18n: "menu.tools.addable.shape", keys: [], route: Global, menu_id: 1102 }; + AddSolid { cpp: "add:solid", i18n: "menu.tools.addable.solid", keys: [], route: Global, menu_id: 1103 }; + AddTitle { cpp: "add:title", i18n: "menu.tools.addable.title", keys: [], route: Global, menu_id: 1104 }; + AddTone { cpp: "add:tone", i18n: "menu.tools.addable.tone", keys: [], route: Global, menu_id: 1105 }; + AddSubtitle { cpp: "add:subtitle", i18n: "menu.tools.addable.subtitle", keys: [], route: Global, menu_id: 1106 }; + Snapping { cpp: "snapping", i18n: "menu.tools.snapping", keys: ["s"], route: Global, menu_id: 1110 }; + UseProxyMedia { cpp: "useproxymedia", i18n: "menu.tools.use_proxy", keys: [], route: Global, menu_id: 1111 }; + ProxySettings { cpp: "proxysettings", i18n: "menu.tools.proxy_settings", keys: [], route: Global, menu_id: 1112 }; + Preferences { cpp: "prefs", i18n: "menu.view.preferences", keys: ["secondary-,"], route: Global, menu_id: 305 }; + + // --- Help --------------------------------------------------------------- + ActionSearch { cpp: "actionsearch", i18n: "menu.help.action_search", keys: ["/"], route: Global, menu_id: 1120 }; + Feedback { cpp: "feedback", i18n: "menu.help.feedback", keys: [], route: Global, menu_id: 1121 }; + About { cpp: "about", i18n: "menu.help.about", keys: [], route: Global, menu_id: 801 }; +} + +/// Where an action is dispatched. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Route { + /// The app shell handles it directly. + Global, + /// The focused panel gets it first (through + /// [`crate::panels::commands::PanelCommandHandler`]); the shell's global + /// handler is the fallback when the panel does not implement it. + FocusedPanel, +} + +/// One registry entry: everything the menu bar, the keymap and the (future) +/// shortcut preferences / action search need to know about an action. +pub struct ActionEntry { + /// The action's identity. + pub action: ActionId, + /// The stable C++ action id (`mainmenu.cpp` / `menushared.cpp`), kept + /// for future shortcut-file (`id\t键序`) compatibility. + pub cpp_id: &'static str, + /// The menu label's i18n key (present in both language tables). + pub i18n_key: &'static str, + /// The default key(s) in gpui keystroke syntax; the first one is the + /// one the menus display. Empty = unbound. + pub default_keys: &'static [&'static str], + /// Where the action routes (shell or focused panel). + pub route: Route, + /// Builds the gpui action value (for key bindings and dispatch). + pub build: fn() -> Box, +} + +impl ActionEntry { + /// The menu item id of this entry's action. + pub const fn menu_id(&self) -> usize { + self.action.menu_id() + } +} + +/// The registry entry bound to a menu item id, if any. +pub fn entry_for_menu_id(id: usize) -> Option<&'static ActionEntry> { + REGISTRY.iter().find(|entry| entry.action.menu_id() == id) +} + +/// The global key bindings for every registry default key (context `None`: +/// the shell dispatches them wherever the focus is, and the modal guard in +/// the shell's action listeners suppresses them while a dialog is open). +pub fn key_bindings() -> Vec { + let mut bindings = Vec::new(); + for entry in REGISTRY { + for key in entry.default_keys { + let binding = KeyBinding::load( + key, + (entry.build)(), + None, + false, + None, + &gpui::DummyKeyboardMapper, + ) + .unwrap_or_else(|_| panic!("invalid default key {key:?} for {}", entry.cpp_id)); + bindings.push(binding); + } + } + bindings +} + +/// The menu-bar label for a keystroke pattern: macOS-style glyphs (⌘⇧⌥⌃) +/// plus arrow / space glyphs, e.g. `secondary-shift-z` → `⇧⌘Z` (on the +/// other platforms the same keys render as `Ctrl+Shift+Z`). The space key +/// name is localized through i18n (`shortcut.space`). +pub fn display_shortcut(action: ActionId) -> Option { + let key = action.entry().default_keys.first()?; + let keystroke = gpui::Keystroke::parse(key).expect("registry keys parse (tests enforce it)"); + + // `secondary-` parses to `platform` on macOS and `control` elsewhere; + // the modifier renderers below follow the same split. + let macos = cfg!(target_os = "macos"); + let mut label = String::new(); + let mut push = |name: &str, glyph: char| { + if macos { + label.push(glyph); + } else { + label.push_str(name); + label.push('+'); + } + }; + if keystroke.modifiers.control { + push("Ctrl", '⌃'); + } + if keystroke.modifiers.alt { + push("Alt", '⌥'); + } + if keystroke.modifiers.shift { + push("Shift", '⇧'); + } + if keystroke.modifiers.platform { + push("Win", '⌘'); + } + match keystroke.key.as_str() { + "left" => label.push('←'), + "right" => label.push('→'), + "up" => label.push('↑'), + "down" => label.push('↓'), + "backspace" => label.push('⌫'), + "delete" => label.push_str("Del"), + "space" => label.push_str(crate::i18n::tr("shortcut.space")), + "home" => label.push_str("Home"), + "end" => label.push_str("End"), + other if other.len() == 1 => label.push_str(&other.to_uppercase()), + other => { + // Named keys (f12, tab, …): capitalize the f-number form. + if let Some(rest) = other.strip_prefix('f') { + label.push_str(&format!("F{rest}")); + } else { + label.push_str(&format!("{}{}", other[..1].to_uppercase(), &other[1..])); + } + } + } + Some(label) +} + +/// The timeline's editing tools (the C++ `Tool` enum's pointer tools): the +/// Tools menu's mutually exclusive group. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Tool { + Pointer, + TrackSelect, + Edit, + Ripple, + Rolling, + Razor, + Slip, + Slide, + Hand, + Zoom, + Transition, + Add, + Record, +} + +impl Tool { + /// The action selecting this tool. + pub const fn action(self) -> ActionId { + match self { + Tool::Pointer => ActionId::PointerTool, + Tool::TrackSelect => ActionId::TrackSelectTool, + Tool::Edit => ActionId::EditTool, + Tool::Ripple => ActionId::RippleTool, + Tool::Rolling => ActionId::RollingTool, + Tool::Razor => ActionId::RazorTool, + Tool::Slip => ActionId::SlipTool, + Tool::Slide => ActionId::SlideTool, + Tool::Hand => ActionId::HandTool, + Tool::Zoom => ActionId::ZoomTool, + Tool::Transition => ActionId::TransitionTool, + Tool::Add => ActionId::AddTool, + Tool::Record => ActionId::RecordTool, + } + } + + /// The tool an action selects, if it is a tool action. + pub const fn from_action(action: ActionId) -> Option { + match action { + ActionId::PointerTool => Some(Tool::Pointer), + ActionId::TrackSelectTool => Some(Tool::TrackSelect), + ActionId::EditTool => Some(Tool::Edit), + ActionId::RippleTool => Some(Tool::Ripple), + ActionId::RollingTool => Some(Tool::Rolling), + ActionId::RazorTool => Some(Tool::Razor), + ActionId::SlipTool => Some(Tool::Slip), + ActionId::SlideTool => Some(Tool::Slide), + ActionId::HandTool => Some(Tool::Hand), + ActionId::ZoomTool => Some(Tool::Zoom), + ActionId::TransitionTool => Some(Tool::Transition), + ActionId::AddTool => Some(Tool::Add), + ActionId::RecordTool => Some(Tool::Record), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every stable id is unique (the shortcut-file format keys on it). + #[test] + fn registry_ids_are_unique() { + let mut seen = std::collections::HashSet::new(); + for entry in REGISTRY { + assert!(seen.insert(entry.cpp_id), "duplicate cpp id {}", entry.cpp_id); + } + } + + /// Every menu id is unique (the menu bar reports plain ids; a duplicate + /// would make two items dispatch the same action). + #[test] + fn registry_menu_ids_are_unique() { + let mut seen = std::collections::HashSet::new(); + for entry in REGISTRY { + assert!( + seen.insert(entry.menu_id()), + "duplicate menu id {} ({})", + entry.menu_id(), + entry.cpp_id + ); + } + } + + /// Every default key parses as a gpui keystroke (the table is static + /// data, so a typo would otherwise surface only as a dead shortcut at + /// runtime — `key_bindings` would panic at startup). + #[test] + fn every_default_key_parses() { + for entry in REGISTRY { + for key in entry.default_keys { + assert!( + gpui::Keystroke::parse(key).is_ok(), + "invalid keystroke {key:?} on {}", + entry.cpp_id + ); + } + } + } + + /// No two actions claim the same keystroke (a conflict would make the + /// keymap dispatch whichever binding was registered last). + #[test] + fn default_keys_are_conflict_free() { + let mut seen = std::collections::HashMap::new(); + for entry in REGISTRY { + for key in entry.default_keys { + let parsed = gpui::Keystroke::parse(key).unwrap(); + let canon = parsed.unparse(); + let previous = seen.insert(canon.clone(), entry.cpp_id); + assert!( + previous.is_none(), + "keystroke {canon} bound to both {} and {}", + previous.unwrap(), + entry.cpp_id + ); + } + } + } + + /// Every registry action appears somewhere in the menu tree (an action + /// without a menu entry is unreachable with the mouse, and the registry + /// is meant to drive the menus). + #[test] + fn every_action_appears_in_the_menu_tree() { + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); + crate::i18n::set_language(crate::i18n::Language::EnUs); + + fn collect(menu: &gpui_widgets::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 crate::app::make_menus_for_test() { + collect(&entry.menu, &mut ids); + } + for entry in REGISTRY { + assert!( + ids.contains(&entry.menu_id()), + "action {} (menu id {}) has no menu item", + entry.cpp_id, + entry.menu_id() + ); + } + } + + /// Every registry i18n key exists in both language tables with a + /// non-empty value (the menu labels come straight from `tr`). + #[test] + fn every_i18n_key_exists_in_both_languages() { + for entry in REGISTRY { + for language in [crate::i18n::Language::EnUs, crate::i18n::Language::ZhCN] { + let _guard = crate::i18n::lang_test_lock().lock().unwrap(); + crate::i18n::set_language(language); + let value = crate::i18n::tr(entry.i18n_key); + assert_ne!( + value, entry.i18n_key, + "i18n key {} ({language:?}) is missing for action {}", + entry.i18n_key, entry.cpp_id + ); + assert!(!value.is_empty()); + } + } + } + + /// The shortcut display formatter renders the documented labels for a + /// sample of shapes (modifier stacks, arrows, named keys, punctuation). + /// The glyph form is the macOS rendering; other platforms spell the + /// modifiers out (covered by the parser tests instead). + #[test] + #[cfg(target_os = "macos")] + fn display_shortcut_formats_labels() { + assert_eq!( + display_shortcut(ActionId::Redo).as_deref(), + Some("⇧⌘Z") + ); + assert_eq!( + display_shortcut(ActionId::SplitAtPlayhead).as_deref(), + Some("⌘K") + ); + assert_eq!( + display_shortcut(ActionId::NudgeLeft).as_deref(), + Some("⌥←") + ); + assert_eq!( + display_shortcut(ActionId::FullScreen).as_deref(), + Some("F11") + ); + assert_eq!( + display_shortcut(ActionId::Insert).as_deref(), + Some(",") + ); + assert!(display_shortcut(ActionId::About).is_none()); + } +} diff --git a/src/app.rs b/src/app.rs index de0929364..ae3209956 100644 --- a/src/app.rs +++ b/src/app.rs @@ -39,7 +39,8 @@ use std::sync::Arc; use std::time::Duration; use gpui::dock::{ - DockArea, DockLayout, DropTarget, DropZone, NodePath, PanelHandle, PanelRegistry, + DockArea, DockEvent, DockLayout, DropTarget, DropZone, NodePath, PanelHandle, PanelId, + PanelRegistry, }; use gpui::timeline::{ ClipData, ClipId, Frame, FrameRange, TimelineEvent, TimelineView, TrackData, @@ -55,8 +56,10 @@ 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::*; @@ -68,63 +71,31 @@ use crate::panels::source_viewer::SourceViewerPanel; use crate::panels::status_bar::StatusBar; use crate::panels::timeline::TimelinePanel; -// Menu item ids (unique per menu). +// 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 { - pub const NEW_PROJECT: usize = 101; - pub const OPEN_PROJECT: usize = 102; - pub const EXPORT_PROJECT: usize = 103; - pub const CLOSE: usize = 105; - pub const EXPORT: usize = 106; - pub const QUIT: usize = 107; - pub const IMPORT_FOOTAGE: usize = 108; - pub const PROJECT_MANAGER: usize = 109; - pub const OPEN_FROM_LIBRARY: usize = 110; + use crate::actions::ActionId; - pub const UNDO: usize = 201; - pub const REDO: usize = 202; - pub const CUT: usize = 203; - pub const COPY: usize = 204; - pub const PASTE: usize = 205; - pub const DELETE: usize = 206; - pub const RIPPLE_DELETE: usize = 207; - pub const SELECT_ALL: usize = 208; + 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 THEME_DARK: usize = 301; - pub const THEME_LIGHT: usize = 302; - pub const LANG_ZH: usize = 303; - pub const LANG_EN: usize = 304; - pub const PREFERENCES: usize = 305; - pub const ZOOM_IN: usize = 306; - pub const ZOOM_OUT: usize = 307; + 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 PLAY_PAUSE: usize = 401; - pub const PREV_FRAME: usize = 402; - pub const NEXT_FRAME: usize = 403; - pub const TO_START: usize = 404; - pub const PLAY: usize = 405; - pub const PAUSE: usize = 406; - pub const SET_IN_POINT: usize = 407; - pub const SET_OUT_POINT: usize = 408; - - pub const ADD_VIDEO_TRACK: usize = 501; - pub const ADD_AUDIO_TRACK: usize = 502; - pub const REMOVE_TRACK: usize = 503; - pub const SPLIT_AT_PLAYHEAD: usize = 504; - pub const ADD_MARKER: usize = 505; - pub const REMOVE_MARKER: usize = 506; - pub const SET_WORKAREA: usize = 507; - pub const CLEAR_WORKAREA: usize = 508; - - pub const FOCUS_PROJECT: usize = 601; - pub const FOCUS_SOURCE_VIEWER: usize = 602; - pub const FOCUS_PROGRAM_VIEWER: usize = 603; - pub const FOCUS_NODE_EDITOR: usize = 604; - pub const FOCUS_INSPECTOR: usize = 605; - pub const FOCUS_HISTORY: usize = 606; - pub const FOCUS_TIMELINE: usize = 607; - pub const FOCUS_EFFECT_LIBRARY: usize = 608; - - pub const ABOUT: usize = 801; + 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`]). @@ -270,7 +241,7 @@ impl PanelRegistry for AppPanelRegistry { cx, )), "history" => Some(PanelHandle::new( - cx.new(|cx| HistoryPanel::new(window, cx)), + cx.new(|cx| HistoryPanel::new(self.engine.clone(), window, cx)), cx, )), "effect-library" => Some(PanelHandle::new( @@ -304,13 +275,43 @@ pub struct OakApp { /// 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 - /// keyboard shortcut layer (the root's `on_key_down`) sits on 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>, } impl OakApp { @@ -346,7 +347,11 @@ impl OakApp { }); // --- menu bar ------------------------------------------------------ - let menu_bar = cx.new(|cx| MenuBar::new(1, make_menus(dark), window, cx)); + // 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| { @@ -357,6 +362,13 @@ impl OakApp { ) .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 { @@ -381,10 +393,36 @@ impl OakApp { }); 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(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)); + // 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(), + }; + + // 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. @@ -488,6 +526,17 @@ impl OakApp { }) .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 @@ -524,8 +573,8 @@ impl OakApp { .detach(); let shell_focus = cx.focus_handle(); - // The shell starts focused so the shortcut layer works before any - // panel grabs focus. + // The shell starts focused so the action dispatch layer works before + // any panel grabs focus. window.focus(&shell_focus, cx); let shell = Self { engine, @@ -540,6 +589,12 @@ impl OakApp { 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. @@ -573,46 +628,158 @@ impl OakApp { cx.notify(); } - /// Routes a menu action. + /// 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) { - 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 = 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)); + tools.push(menu_item(A::ProxySettings)); + vec![ MenuBarEntry::new( tr("menu.file"), Menu::new(vec![ - MenuItem::new(NEW_PROJECT, tr("menu.file.new_project")).with_shortcut("⌘N"), - MenuItem::new(OPEN_FROM_LIBRARY, tr("menu.file.open_library")), - MenuItem::new(OPEN_PROJECT, tr("menu.file.open_project")).with_shortcut("⌘O"), - MenuItem::new(PROJECT_MANAGER, tr("menu.file.project_manager")).separated(), - MenuItem::new(IMPORT_FOOTAGE, tr("menu.file.import_footage")), - MenuItem::new(EXPORT_PROJECT, tr("menu.file.export_project")) - .with_shortcut("⌘S") - .separated(), - MenuItem::new(CLOSE, tr("menu.file.close")), - MenuItem::new(EXPORT, tr("menu.file.export")) - .with_shortcut("⌘E") - .separated(), - MenuItem::new(QUIT, tr("menu.file.quit")) - .with_shortcut("⌘Q") - .separated(), + // 新建 ▸ 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![ - MenuItem::new(UNDO, tr("menu.edit.undo")).with_shortcut("⌘Z"), - MenuItem::new(REDO, tr("menu.edit.redo")) - .with_shortcut("⇧⌘Z") - .separated(), - MenuItem::new(CUT, tr("menu.edit.cut")).with_shortcut("⌘X"), - MenuItem::new(COPY, tr("menu.edit.copy")).with_shortcut("⌘C"), - MenuItem::new(PASTE, tr("menu.edit.paste")).with_shortcut("⌘V"), - MenuItem::new(DELETE, tr("menu.edit.delete")).with_shortcut("⌫"), - MenuItem::new(RIPPLE_DELETE, tr("menu.edit.ripple_delete")) - .with_shortcut("⇧⌫") - .separated(), - MenuItem::new(SELECT_ALL, tr("menu.edit.select_all")).with_shortcut("A"), + 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![ - MenuItem::new(THEME_DARK, tr("menu.view.theme")).with_submenu(theme_submenu), - MenuItem::new(LANG_ZH, tr("menu.view.language")).with_submenu(language_submenu), - MenuItem::new(ZOOM_IN, tr("menu.view.zoom_in")) - .with_shortcut("+") - .separated(), - MenuItem::new(ZOOM_OUT, tr("menu.view.zoom_out")).with_shortcut("-"), - MenuItem::new(PREFERENCES, tr("menu.view.preferences")) - .with_shortcut("⌘,") - .separated(), + 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![ - MenuItem::new(PLAY_PAUSE, tr("menu.playback.play_pause")).with_shortcut("空格"), - MenuItem::new(PLAY, tr("menu.playback.play")).with_shortcut("L"), - MenuItem::new(PAUSE, tr("menu.playback.pause")).with_shortcut("K"), - MenuItem::new(PREV_FRAME, tr("menu.playback.prev_frame")).with_shortcut("←"), - MenuItem::new(NEXT_FRAME, tr("menu.playback.next_frame")) - .with_shortcut("→") - .separated(), - MenuItem::new(TO_START, tr("menu.playback.to_start")).with_shortcut("Home"), - MenuItem::new(SET_IN_POINT, tr("menu.playback.set_in_point")).with_shortcut("I"), - MenuItem::new(SET_OUT_POINT, tr("menu.playback.set_out_point")).with_shortcut("O"), + 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![ - MenuItem::new(ADD_VIDEO_TRACK, tr("menu.sequence.add_video_track")), - MenuItem::new(ADD_AUDIO_TRACK, tr("menu.sequence.add_audio_track")), - MenuItem::new(REMOVE_TRACK, tr("menu.sequence.remove_track")).separated(), - MenuItem::new(SPLIT_AT_PLAYHEAD, tr("menu.sequence.split_at_playhead")) - .with_shortcut("S"), - MenuItem::new(ADD_MARKER, tr("menu.sequence.add_marker")).with_shortcut("M"), - MenuItem::new(REMOVE_MARKER, tr("menu.sequence.remove_marker")).separated(), - MenuItem::new(SET_WORKAREA, tr("menu.sequence.set_workarea")), - MenuItem::new(CLEAR_WORKAREA, tr("menu.sequence.clear_workarea")), - MenuItem::new(704, tr("menu.sequence.settings")).disabled(), + 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![ - MenuItem::new(FOCUS_PROJECT, tr("menu.window.project")), - MenuItem::new(FOCUS_SOURCE_VIEWER, tr("menu.window.source_viewer")), - MenuItem::new(FOCUS_PROGRAM_VIEWER, tr("menu.window.program_viewer")), - MenuItem::new(FOCUS_NODE_EDITOR, tr("menu.window.node_editor")), - MenuItem::new(FOCUS_INSPECTOR, tr("menu.window.inspector")), - MenuItem::new(FOCUS_HISTORY, tr("menu.window.history")), - MenuItem::new(FOCUS_TIMELINE, tr("menu.window.timeline")), - MenuItem::new(FOCUS_EFFECT_LIBRARY, tr("menu.window.effect_library")), - ]), - ), - MenuBarEntry::new( - tr("menu.tools"), - Menu::new(vec![ - MenuItem::new(701, tr("menu.tools.select")), - MenuItem::new(702, tr("menu.tools.razor")), - MenuItem::new(703, tr("menu.tools.snap")).with_checked(true), + 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).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![MenuItem::new(ABOUT, tr("menu.help.about"))]), + 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 { @@ -1843,7 +2217,7 @@ mod tests { let _guard = crate::i18n::lang_test_lock().lock().unwrap(); let view_entry = |dark: bool| -> MenuBarEntry { - make_menus(dark) + make_menus(MenuState::new(dark)) .into_iter() .find(|entry| entry.title == crate::i18n::tr("menu.view")) .expect("视图/View menu exists") @@ -1904,7 +2278,7 @@ mod tests { let _guard = crate::i18n::lang_test_lock().lock().unwrap(); let dark_item = |dark: bool| -> gpui_widgets::menu::MenuItem { - let entries = make_menus(dark); + let entries = make_menus(MenuState::new(dark)); let view = entries .iter() .find(|entry| entry.title == crate::i18n::tr("menu.view")) @@ -1935,7 +2309,7 @@ mod tests { let _guard = crate::i18n::lang_test_lock().lock().unwrap(); let entry = |title: &str| -> MenuBarEntry { - make_menus(true) + make_menus_for_test() .into_iter() .find(|entry| entry.title == title) .expect("menu exists") @@ -2016,8 +2390,9 @@ mod tests { // Keyboard shortcuts (M12 P5c) // ------------------------------------------------------------------- - /// Every shortcut-table action exists as a menu item (recursing into - /// submenus), so a key press can never dispatch a dead action. + /// 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(); @@ -2032,21 +2407,26 @@ mod tests { } } let mut ids = Vec::new(); - for entry in make_menus(true) { + for entry in make_menus_for_test() { collect(&entry.menu, &mut ids); } - for shortcut in crate::shortcuts::SHORTCUTS { + for entry in crate::actions::REGISTRY { + if entry.default_keys.is_empty() { + continue; + } assert!( - ids.contains(&shortcut.action), + ids.contains(&entry.menu_id()), "shortcut {} → action {} has no menu item", - shortcut.keystroke, - shortcut.action + entry.default_keys[0], + entry.cpp_id ); } } - /// The menu's shortcut labels mirror the shortcut table's display - /// strings (a drift between them would show the user the wrong key). + /// 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(); @@ -2062,10 +2442,13 @@ mod tests { } out } - for entry in make_menus(true) { + for entry in make_menus_for_test() { for (id, label) in walk(&entry.menu) { let Some(label) = label else { continue }; - let expected = crate::shortcuts::display_for(id) + 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(), @@ -2103,7 +2486,121 @@ mod tests { assert!(!playing, "space again pauses program playback"); } - /// ⌘Z on the shell dispatches 编辑 → 撤销 to the engine, and "s" splits + /// 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) { @@ -2111,7 +2608,7 @@ mod tests { let (window, root) = mock_shell(cx); // Move the playhead inside the first clip (the → shortcut steps one - // frame), then split with "s": the mock sequence grows by one clip. + // 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()); } @@ -2126,7 +2623,10 @@ mod tests { .map(|t| t.clips().len()) .sum() }); - cx.dispatch_keystroke(window.into(), gpui::Keystroke::parse("s").unwrap()); + 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); @@ -2164,7 +2664,8 @@ mod tests { cx.update(|app| root.update(app, |app, cx| app.on_menu(menu_ids::PREFERENCES, cx))); cx.run_until_parked(); - // "s" would split at the playhead without the modal guard. + // ⌘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()) @@ -2172,6 +2673,12 @@ mod tests { .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(), @@ -2179,16 +2686,24 @@ mod tests { ); cx.run_until_parked(); - let (clips_after, playing) = cx.read(|app| { + 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()) + ( + 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" diff --git a/src/i18n.rs b/src/i18n.rs index 2088ca128..96d141f7e 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -179,14 +179,22 @@ const EN: &[(&str, &str)] = &[ ("menu.tools", "Tools(T)"), ("menu.help", "Help(H)"), // --- File --- + ("menu.file.new", "New"), ("menu.file.new_project", "New Project…"), + ("menu.file.new_sequence", "New Sequence…"), + ("menu.file.new_folder", "New Folder"), ("menu.file.open_project", "Open Project File…"), ("menu.file.open_library", "Open from Library…"), + ("menu.file.open_recent", "Open Recent"), + ("menu.file.clear_recent", "Clear Recent List"), ("menu.file.project_manager", "Project Manager…"), ("menu.file.import_footage", "Import Footage…"), ("menu.file.export_project", "Export Project File…"), + ("menu.file.save_as", "Save As…"), + ("menu.file.revert", "Revert"), + ("menu.file.export_media", "Export Media…"), + ("menu.file.project_properties", "Project Properties…"), ("menu.file.close", "Close Project"), - ("menu.file.export", "Export…"), ("menu.file.quit", "Quit"), // --- Edit --- ("menu.edit.undo", "Undo"), @@ -194,9 +202,37 @@ const EN: &[(&str, &str)] = &[ ("menu.edit.cut", "Cut"), ("menu.edit.copy", "Copy"), ("menu.edit.paste", "Paste"), + ("menu.edit.paste_insert", "Paste Insert"), + ("menu.edit.duplicate", "Duplicate"), + ("menu.edit.rename", "Rename"), ("menu.edit.delete", "Delete"), ("menu.edit.ripple_delete", "Ripple Delete"), + ("menu.edit.split", "Split at Playhead"), + ("menu.edit.speed_duration", "Speed/Duration…"), + ("menu.edit.default_transition", "Set as Default Transition"), + ("menu.edit.link_unlink", "Link/Unlink"), + ("menu.edit.enable_disable", "Enable/Disable"), + ("menu.edit.nest", "Nest"), ("menu.edit.select_all", "Select All"), + ("menu.edit.deselect_all", "Deselect All"), + ("menu.edit.insert", "Insert"), + ("menu.edit.overwrite", "Overwrite"), + ("menu.edit.ripple_to_in", "Ripple to In"), + ("menu.edit.ripple_to_out", "Ripple to Out"), + ("menu.edit.edit_to_in", "Edit to In"), + ("menu.edit.edit_to_out", "Edit to Out"), + ("menu.edit.nudge_left", "Nudge Left"), + ("menu.edit.nudge_right", "Nudge Right"), + ("menu.edit.move_in_to_playhead", "Move In Point to Playhead"), + ("menu.edit.move_out_to_playhead", "Move Out Point to Playhead"), + ("menu.edit.set_in_point", "Set In Point"), + ("menu.edit.set_out_point", "Set Out Point"), + ("menu.edit.reset_in", "Reset In Point"), + ("menu.edit.reset_out", "Reset Out Point"), + ("menu.edit.clear_in_out", "Clear In/Out"), + ("menu.edit.delete_in_out", "Delete In to Out"), + ("menu.edit.ripple_delete_in_out", "Ripple Delete In to Out"), + ("menu.edit.marker", "Add Marker"), // --- View --- ("menu.view.theme", "Theme"), ("menu.view.theme.dark", "Olive Dark"), @@ -206,25 +242,37 @@ const EN: &[(&str, &str)] = &[ ("menu.view.language.zh", "简体中文"), ("menu.view.zoom_in", "Zoom In"), ("menu.view.zoom_out", "Zoom Out"), + ("menu.view.increase_track_height", "Increase Track Height"), + ("menu.view.decrease_track_height", "Decrease Track Height"), + ("menu.view.show_all", "Show All Tracks"), + ("menu.view.full_screen", "Full Screen"), + ("menu.view.full_screen_viewer", "Full Screen Viewer"), ("menu.view.preferences", "Preferences…"), // --- Playback --- ("menu.playback.play_pause", "Play/Pause"), - ("menu.playback.play", "Play"), - ("menu.playback.pause", "Pause"), + ("menu.playback.play_in_to_out", "Play In to Out"), + ("menu.playback.loop", "Loop"), + ("menu.playback.shuttle_left", "Shuttle Left"), + ("menu.playback.shuttle_stop", "Shuttle Stop"), + ("menu.playback.shuttle_right", "Shuttle Right"), ("menu.playback.prev_frame", "Previous Frame"), ("menu.playback.next_frame", "Next Frame"), ("menu.playback.to_start", "Jump to Sequence Start"), - ("menu.playback.set_in_point", "Set In Point"), - ("menu.playback.set_out_point", "Set Out Point"), + ("menu.playback.go_to_end", "Jump to Sequence End"), + ("menu.playback.prev_cut", "Go to Previous Cut"), + ("menu.playback.next_cut", "Go to Next Cut"), + ("menu.playback.go_to_in", "Go to In Point"), + ("menu.playback.go_to_out", "Go to Out Point"), // --- Sequence --- ("menu.sequence.add_video_track", "Add Video Track"), ("menu.sequence.add_audio_track", "Add Audio Track"), ("menu.sequence.remove_track", "Remove Selected Track"), - ("menu.sequence.split_at_playhead", "Split Clips at Playhead"), - ("menu.sequence.add_marker", "Add Marker"), ("menu.sequence.remove_marker", "Remove Marker"), ("menu.sequence.set_workarea", "Set Work Area"), ("menu.sequence.clear_workarea", "Clear Work Area"), + ("menu.sequence.cache", "Render Cache"), + ("menu.sequence.cache_in_out", "Render Cache In to Out"), + ("menu.sequence.cache_clear", "Clear Render Cache"), ("menu.sequence.settings", "Sequence Settings…"), // --- Window --- ("menu.window.project", "Project"), @@ -235,12 +283,38 @@ const EN: &[(&str, &str)] = &[ ("menu.window.history", "History"), ("menu.window.timeline", "Timeline"), ("menu.window.effect_library", "Effect Library"), + ("menu.window.maximize_panel", "Maximize Panel"), + ("menu.window.reset_layout", "Reset Layout"), // --- Tools --- - ("menu.tools.select", "Select"), - ("menu.tools.razor", "Razor"), - ("menu.tools.snap", "Snap"), + ("menu.tools.pointer", "Pointer"), + ("menu.tools.track_select", "Track Select"), + ("menu.tools.edit", "Edit"), + ("menu.tools.ripple", "Ripple"), + ("menu.tools.rolling", "Rolling"), + ("menu.tools.razor_tool", "Razor"), + ("menu.tools.slip", "Slip"), + ("menu.tools.slide", "Slide"), + ("menu.tools.hand", "Hand"), + ("menu.tools.zoom_tool", "Zoom"), + ("menu.tools.transition", "Transition"), + ("menu.tools.add", "Add"), + ("menu.tools.record", "Record"), + ("menu.tools.addable.empty", "Empty Clip"), + ("menu.tools.addable.bars", "Color Bars"), + ("menu.tools.addable.shape", "Shape"), + ("menu.tools.addable.solid", "Solid"), + ("menu.tools.addable.title", "Title"), + ("menu.tools.addable.tone", "Test Tone"), + ("menu.tools.addable.subtitle", "Subtitle"), + ("menu.tools.snapping", "Snapping"), + ("menu.tools.use_proxy", "Use Proxy Media"), + ("menu.tools.proxy_settings", "Proxy Settings…"), // --- Help --- + ("menu.help.action_search", "Search Actions…"), + ("menu.help.feedback", "Send Feedback…"), ("menu.help.about", "About Oak…"), + // --- shortcut glyphs --- + ("shortcut.space", "Space"), // --- dock panel titles --- ("panel.project", "Project"), ("panel.source_viewer", "Source Viewer"), @@ -301,12 +375,10 @@ const EN: &[(&str, &str)] = &[ // --- project bin --- ("bin.footage", "Footage"), ("bin.music", "Music"), - // --- history (undo stack demo entries) --- - ("history.transform", "Transform"), - ("history.move_clip", "Move Clip"), - ("history.delete_clip", "Delete"), - ("history.add_lut", "Add OCIO LUT"), - ("history.set_in_point", "Set In Point"), + // --- history (real undo stack) --- + ("history.command", "Command"), + ("history.empty", "No History"), + ("history.jump_here", "Jump to This Step"), // --- node editor --- ("node.fit", "Fit"), // --- program viewer tabs and scope labels --- @@ -373,6 +445,119 @@ const EN: &[(&str, &str)] = &[ ("export.hint", "The sequence is exported through the oaktask export path; progress is shown in the dialog."), ("export.progress.title", "Exporting"), ("export.progress.label", "Rendering frames…"), + // --- color labels --- + ("menu.color.label", "Color Label"), + ("menu.color.red", "Red"), + ("menu.color.maroon", "Maroon"), + ("menu.color.orange", "Orange"), + ("menu.color.brown", "Brown"), + ("menu.color.yellow", "Yellow"), + ("menu.color.oak", "Oak"), + ("menu.color.lime", "Lime"), + ("menu.color.green", "Green"), + ("menu.color.cyan", "Cyan"), + ("menu.color.teal", "Teal"), + ("menu.color.blue", "Blue"), + ("menu.color.navy", "Navy"), + ("menu.color.pink", "Pink"), + ("menu.color.purple", "Purple"), + ("menu.color.silver", "Silver"), + ("menu.color.gray", "Gray"), + // --- shared context items --- + ("menu.context.properties", "Properties"), + // --- timeline context menu --- + ("timeline.context.sync_source_time", "Synchronize by Source Time"), + ("timeline.context.sync_waveform", "Synchronize by Waveform"), + ( + "timeline.context.sync_waveform_speed", + "Synchronize by Waveform (Adjust Speed)", + ), + ("timeline.context.cache", "Cache"), + ("timeline.context.auto_cache", "Auto-Cache"), + ("timeline.context.cache_all", "Cache All"), + ("timeline.context.cache_in_out", "Cache In/Out"), + ("timeline.context.cache_discard", "Discard"), + ("timeline.context.proxy", "Proxy"), + ("timeline.context.generate_proxy", "Generate Proxy"), + ("timeline.context.use_proxy", "Use Proxy"), + ("timeline.context.reveal_proxy", "Reveal Proxy"), + ("timeline.context.delete_proxy", "Delete Proxy"), + ( + "timeline.context.reveal_in_footage_viewer", + "Reveal in Footage Viewer", + ), + ("timeline.context.reveal_in_project", "Reveal in Project"), + ("timeline.context.multicam", "Multi-Cam"), + ("timeline.context.use_audio_time_units", "Use Audio Time Units"), + ("timeline.context.show_thumbnails", "Show Thumbnails"), + ("timeline.context.thumbnails_off", "Disabled"), + ("timeline.context.thumbnails_at_in_points", "Only At In Points"), + ("timeline.context.thumbnails_on", "Enabled"), + ("timeline.context.show_waveforms", "Show Waveforms"), + ("timeline.context.delete_track", "Delete"), + ("timeline.context.delete_all_empty", "Delete All Empty"), + ("timeline.context.timecode_drop_frame", "Drop Frame"), + ("timeline.context.timecode_non_drop_frame", "Non-Drop Frame"), + ("timeline.context.timecode_seconds", "Seconds"), + ("timeline.context.timecode_frames", "Frames"), + ("timeline.context.timecode_milliseconds", "Milliseconds"), + // --- node categories --- + ("node.category.output", "Output"), + ("node.category.effect", "Effect"), + ("node.category.generator", "Generator"), + ("node.category.input", "Input"), + ("node.category.math", "Math"), + ("node.category.color", "Color"), + ("node.category.distort", "Distort"), + ("node.category.filter", "Filter"), + ("node.category.keying", "Keying"), + ("node.category.openfx", "OpenFX"), + ("node.category.group", "Group"), + // --- project explorer context menu --- + ("project.context.new", "New"), + ("project.context.reveal_in_finder", "Reveal in Finder"), + ("project.context.replace_footage", "Replace Footage"), + ("project.context.rename", "Rename"), + ("project.context.delete", "Delete"), + ("project.context.open_in_new_tab", "Open in New Tab"), + ("project.context.open_in_new_window", "Open in New Window"), + // --- viewer context menu --- + ("viewer.context.zoom", "Zoom"), + ("viewer.context.zoom_fit", "Fit"), + ("viewer.context.full_screen", "Full Screen"), + ("viewer.context.playback_resolution", "Playback Resolution"), + ("viewer.context.res_full", "Full"), + ("viewer.context.res_half", "1/2"), + ("viewer.context.res_quarter", "1/4"), + ("viewer.context.res_eighth", "1/8"), + ("viewer.context.safe_margins", "Safe Margins"), + ("viewer.context.safe_off", "Off"), + ("viewer.context.safe_on", "On"), + ("viewer.context.safe_custom", "Custom Aspect"), + ("viewer.context.stop_on_last", "Stop Playback On Last Frame"), + ("viewer.context.audio_waveform", "Audio Waveform"), + ("viewer.context.wf_automatic", "Automatically Show/Hide"), + ("viewer.context.wf_only", "Show Waveform Only"), + ("viewer.context.wf_both", "Show Both Viewer And Waveform"), + ("viewer.context.show_fps", "Show FPS"), + ("viewer.context.save_frame", "Save Frame As Image"), + // --- node editor context menu --- + ("node.context.group", "Group"), + ("node.context.ungroup", "Ungroup"), + ("node.context.open_in_viewer", "Open in Viewer"), + ("node.context.show_in_param_editor", "Show in Parameter Editor"), + ("node.context.smooth_edges", "Smooth Edges"), + ("node.context.direction", "Direction"), + ("node.context.dir_top_bottom", "Top to Bottom"), + ("node.context.dir_bottom_top", "Bottom to Top"), + ("node.context.dir_left_right", "Left to Right"), + ("node.context.dir_right_left", "Right to Left"), + ("node.context.add", "Add"), + // --- inspector context menu --- + ("inspector.context.enable", "Enable"), + ("inspector.context.disable", "Disable"), + ("inspector.context.remove", "Remove"), + ("inspector.context.rename", "Rename"), ]; /// The zh-CN table. Mirrors [`EN`] key-for-key. @@ -387,14 +572,22 @@ const ZH: &[(&str, &str)] = &[ ("menu.tools", "工具(T)"), ("menu.help", "帮助(H)"), // --- File --- + ("menu.file.new", "新建"), ("menu.file.new_project", "新建项目…"), + ("menu.file.new_sequence", "新建序列…"), + ("menu.file.new_folder", "新建文件夹"), ("menu.file.open_project", "打开工程文件…"), ("menu.file.open_library", "从库中打开…"), + ("menu.file.open_recent", "最近打开"), + ("menu.file.clear_recent", "清除最近列表"), ("menu.file.project_manager", "项目管理器…"), ("menu.file.import_footage", "导入素材…"), ("menu.file.export_project", "导出工程文件…"), + ("menu.file.save_as", "另存为…"), + ("menu.file.revert", "还原"), + ("menu.file.export_media", "导出媒体…"), + ("menu.file.project_properties", "项目属性…"), ("menu.file.close", "关闭项目"), - ("menu.file.export", "导出…"), ("menu.file.quit", "退出"), // --- Edit --- ("menu.edit.undo", "撤销"), @@ -402,9 +595,37 @@ const ZH: &[(&str, &str)] = &[ ("menu.edit.cut", "剪切"), ("menu.edit.copy", "复制"), ("menu.edit.paste", "粘贴"), + ("menu.edit.paste_insert", "粘贴插入"), + ("menu.edit.duplicate", "创建副本"), + ("menu.edit.rename", "重命名"), ("menu.edit.delete", "删除"), ("menu.edit.ripple_delete", "波纹删除"), + ("menu.edit.split", "在播放头处分割"), + ("menu.edit.speed_duration", "速度/持续时间…"), + ("menu.edit.default_transition", "设为默认转场"), + ("menu.edit.link_unlink", "链接/取消链接"), + ("menu.edit.enable_disable", "启用/禁用"), + ("menu.edit.nest", "嵌套"), ("menu.edit.select_all", "全选"), + ("menu.edit.deselect_all", "取消全选"), + ("menu.edit.insert", "插入"), + ("menu.edit.overwrite", "覆盖"), + ("menu.edit.ripple_to_in", "波纹修剪到入点"), + ("menu.edit.ripple_to_out", "波纹修剪到出点"), + ("menu.edit.edit_to_in", "编辑到入点"), + ("menu.edit.edit_to_out", "编辑到出点"), + ("menu.edit.nudge_left", "向左微调"), + ("menu.edit.nudge_right", "向右微调"), + ("menu.edit.move_in_to_playhead", "移动入点到播放头"), + ("menu.edit.move_out_to_playhead", "移动出点到播放头"), + ("menu.edit.set_in_point", "设置入点"), + ("menu.edit.set_out_point", "设置出点"), + ("menu.edit.reset_in", "重置入点"), + ("menu.edit.reset_out", "重置出点"), + ("menu.edit.clear_in_out", "清除入出点"), + ("menu.edit.delete_in_out", "删除入出点之间"), + ("menu.edit.ripple_delete_in_out", "波纹删除入出点之间"), + ("menu.edit.marker", "添加标记"), // --- View --- ("menu.view.theme", "主题"), ("menu.view.theme.dark", "Olive Dark"), @@ -414,25 +635,37 @@ const ZH: &[(&str, &str)] = &[ ("menu.view.language.zh", "简体中文"), ("menu.view.zoom_in", "放大"), ("menu.view.zoom_out", "缩小"), + ("menu.view.increase_track_height", "增加轨道高度"), + ("menu.view.decrease_track_height", "降低轨道高度"), + ("menu.view.show_all", "显示全部轨道"), + ("menu.view.full_screen", "全屏"), + ("menu.view.full_screen_viewer", "全屏查看器"), ("menu.view.preferences", "偏好设置…"), // --- Playback --- ("menu.playback.play_pause", "播放/暂停"), - ("menu.playback.play", "播放"), - ("menu.playback.pause", "暂停"), + ("menu.playback.play_in_to_out", "从入点播放到出点"), + ("menu.playback.loop", "循环播放"), + ("menu.playback.shuttle_left", "穿梭左"), + ("menu.playback.shuttle_stop", "穿梭停止"), + ("menu.playback.shuttle_right", "穿梭右"), ("menu.playback.prev_frame", "上一帧"), ("menu.playback.next_frame", "下一帧"), ("menu.playback.to_start", "跳到序列起点"), - ("menu.playback.set_in_point", "设置入点"), - ("menu.playback.set_out_point", "设置出点"), + ("menu.playback.go_to_end", "跳到序列终点"), + ("menu.playback.prev_cut", "跳到上一剪辑点"), + ("menu.playback.next_cut", "跳到下一剪辑点"), + ("menu.playback.go_to_in", "跳到入点"), + ("menu.playback.go_to_out", "跳到出点"), // --- Sequence --- ("menu.sequence.add_video_track", "添加视频轨道"), ("menu.sequence.add_audio_track", "添加音频轨道"), ("menu.sequence.remove_track", "删除所选轨道"), - ("menu.sequence.split_at_playhead", "在播放头处分割片段"), - ("menu.sequence.add_marker", "添加标记"), ("menu.sequence.remove_marker", "清除标记"), ("menu.sequence.set_workarea", "设置工作区"), ("menu.sequence.clear_workarea", "清除工作区"), + ("menu.sequence.cache", "渲染缓存"), + ("menu.sequence.cache_in_out", "渲染缓存入出点之间"), + ("menu.sequence.cache_clear", "清除渲染缓存"), ("menu.sequence.settings", "序列设置…"), // --- Window --- ("menu.window.project", "项目"), @@ -443,12 +676,38 @@ const ZH: &[(&str, &str)] = &[ ("menu.window.history", "历史记录"), ("menu.window.timeline", "时间线"), ("menu.window.effect_library", "效果库"), + ("menu.window.maximize_panel", "最大化面板"), + ("menu.window.reset_layout", "重置布局"), // --- Tools --- - ("menu.tools.select", "选择"), - ("menu.tools.razor", "剃刀"), - ("menu.tools.snap", "吸附"), + ("menu.tools.pointer", "指针"), + ("menu.tools.track_select", "轨道选择"), + ("menu.tools.edit", "编辑"), + ("menu.tools.ripple", "波纹"), + ("menu.tools.rolling", "滚动"), + ("menu.tools.razor_tool", "剃刀"), + ("menu.tools.slip", "滑移"), + ("menu.tools.slide", "滑动"), + ("menu.tools.hand", "抓手"), + ("menu.tools.zoom_tool", "缩放"), + ("menu.tools.transition", "转场"), + ("menu.tools.add", "添加"), + ("menu.tools.record", "录制"), + ("menu.tools.addable.empty", "空白片段"), + ("menu.tools.addable.bars", "彩条"), + ("menu.tools.addable.shape", "形状"), + ("menu.tools.addable.solid", "纯色"), + ("menu.tools.addable.title", "标题"), + ("menu.tools.addable.tone", "测试音"), + ("menu.tools.addable.subtitle", "字幕"), + ("menu.tools.snapping", "吸附"), + ("menu.tools.use_proxy", "使用代理媒体"), + ("menu.tools.proxy_settings", "代理设置…"), // --- Help --- + ("menu.help.action_search", "搜索动作…"), + ("menu.help.feedback", "发送反馈…"), ("menu.help.about", "关于 Oak…"), + // --- shortcut glyphs --- + ("shortcut.space", "空格"), // --- dock panel titles --- ("panel.project", "项目"), ("panel.source_viewer", "素材查看器"), @@ -512,12 +771,10 @@ const ZH: &[(&str, &str)] = &[ // --- project bin --- ("bin.footage", "素材"), ("bin.music", "音乐"), - // --- history (undo stack demo entries) --- - ("history.transform", "变换"), - ("history.move_clip", "移动片段"), - ("history.delete_clip", "删除"), - ("history.add_lut", "添加 OCIO LUT"), - ("history.set_in_point", "设置入点"), + // --- history (real undo stack) --- + ("history.command", "命令"), + ("history.empty", "暂无历史记录"), + ("history.jump_here", "跳转到此步骤"), // --- node editor --- ("node.fit", "适配"), // --- program viewer tabs and scope labels --- @@ -590,6 +847,113 @@ const ZH: &[(&str, &str)] = &[ ), ("export.progress.title", "正在导出"), ("export.progress.label", "正在渲染帧…"), + // --- 颜色标签 --- + ("menu.color.label", "颜色标签"), + ("menu.color.red", "红色"), + ("menu.color.maroon", "紫褐色"), + ("menu.color.orange", "橙色"), + ("menu.color.brown", "棕色"), + ("menu.color.yellow", "黄色"), + ("menu.color.oak", "橄榄色"), + ("menu.color.lime", "黄绿色"), + ("menu.color.green", "绿色"), + ("menu.color.cyan", "青色"), + ("menu.color.teal", "蓝绿色"), + ("menu.color.blue", "蓝色"), + ("menu.color.navy", "深蓝色"), + ("menu.color.pink", "粉色"), + ("menu.color.purple", "紫色"), + ("menu.color.silver", "银色"), + ("menu.color.gray", "灰色"), + // --- 通用右键项 --- + ("menu.context.properties", "属性"), + // --- 时间线右键菜单 --- + ("timeline.context.sync_source_time", "按源时间同步"), + ("timeline.context.sync_waveform", "按波形同步"), + ("timeline.context.sync_waveform_speed", "按波形同步(调整速度)"), + ("timeline.context.cache", "缓存"), + ("timeline.context.auto_cache", "自动缓存"), + ("timeline.context.cache_all", "缓存全部"), + ("timeline.context.cache_in_out", "缓存入点/出点"), + ("timeline.context.cache_discard", "丢弃"), + ("timeline.context.proxy", "代理"), + ("timeline.context.generate_proxy", "生成代理"), + ("timeline.context.use_proxy", "使用代理"), + ("timeline.context.reveal_proxy", "显示代理"), + ("timeline.context.delete_proxy", "删除代理"), + ("timeline.context.reveal_in_footage_viewer", "在素材查看器中显示"), + ("timeline.context.reveal_in_project", "在项目中显示"), + ("timeline.context.multicam", "多机位"), + ("timeline.context.use_audio_time_units", "使用音频时间单位"), + ("timeline.context.show_thumbnails", "显示缩略图"), + ("timeline.context.thumbnails_off", "关闭"), + ("timeline.context.thumbnails_at_in_points", "仅在入点"), + ("timeline.context.thumbnails_on", "开启"), + ("timeline.context.show_waveforms", "显示波形"), + ("timeline.context.delete_track", "删除"), + ("timeline.context.delete_all_empty", "删除所有空轨道"), + ("timeline.context.timecode_drop_frame", "丢帧"), + ("timeline.context.timecode_non_drop_frame", "不丢帧"), + ("timeline.context.timecode_seconds", "秒"), + ("timeline.context.timecode_frames", "帧"), + ("timeline.context.timecode_milliseconds", "毫秒"), + // --- 节点分类 --- + ("node.category.output", "输出"), + ("node.category.effect", "效果"), + ("node.category.generator", "生成器"), + ("node.category.input", "输入"), + ("node.category.math", "数学"), + ("node.category.color", "颜色"), + ("node.category.distort", "变形"), + ("node.category.filter", "滤镜"), + ("node.category.keying", "抠像"), + ("node.category.openfx", "OpenFX"), + ("node.category.group", "组"), + // --- 项目浏览器右键菜单 --- + ("project.context.new", "新建"), + ("project.context.reveal_in_finder", "在 Finder 中显示"), + ("project.context.replace_footage", "替换素材"), + ("project.context.rename", "重命名"), + ("project.context.delete", "删除"), + ("project.context.open_in_new_tab", "在新标签页中打开"), + ("project.context.open_in_new_window", "在新窗口中打开"), + // --- 查看器右键菜单 --- + ("viewer.context.zoom", "缩放"), + ("viewer.context.zoom_fit", "适合"), + ("viewer.context.full_screen", "全屏"), + ("viewer.context.playback_resolution", "播放分辨率"), + ("viewer.context.res_full", "完整"), + ("viewer.context.res_half", "1/2"), + ("viewer.context.res_quarter", "1/4"), + ("viewer.context.res_eighth", "1/8"), + ("viewer.context.safe_margins", "安全边距"), + ("viewer.context.safe_off", "关闭"), + ("viewer.context.safe_on", "开启"), + ("viewer.context.safe_custom", "自定义宽高比"), + ("viewer.context.stop_on_last", "在最后一帧停止播放"), + ("viewer.context.audio_waveform", "音频波形"), + ("viewer.context.wf_automatic", "自动显示/隐藏"), + ("viewer.context.wf_only", "仅显示波形"), + ("viewer.context.wf_both", "同时显示画面和波形"), + ("viewer.context.show_fps", "显示FPS"), + ("viewer.context.save_frame", "将帧另存为图像"), + // --- 节点编辑器右键菜单 --- + ("node.context.group", "组合"), + ("node.context.ungroup", "取消组合"), + ("node.context.open_in_viewer", "在查看器中打开"), + ("node.context.show_in_param_editor", "在参数编辑器中显示"), + ("node.context.smooth_edges", "平滑边缘"), + ("node.context.direction", "方向"), + ("node.context.dir_top_bottom", "从上到下"), + ("node.context.dir_bottom_top", "从下到上"), + ("node.context.dir_left_right", "从左到右"), + ("node.context.dir_right_left", "从右到左"), + ("node.context.add", "添加"), + // --- 检查器右键菜单 --- + ("inspector.context.enable", "启用"), + ("inspector.context.disable", "禁用"), + ("inspector.context.remove", "移除"), + ("inspector.context.rename", "重命名"), ]; // --------------------------------------------------------------------------- @@ -651,6 +1015,10 @@ mod tests { "Metal", "Vulkan", "MP4", + "OpenFX", + "1/2", + "1/4", + "1/8", ]; for (key, en_value) in EN { assert_ne!( diff --git a/src/lib.rs b/src/lib.rs index 383df445d..2fb924005 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -33,9 +33,12 @@ //! * [`app`] — the window shell: menu bar, dock layout, status bar, modal //! dialogs (file open/save-as, preferences, export), tick loop. //! * [`dialogs`] — the preferences and export dialog content views. -//! * [`shortcuts`] — the keyboard shortcut table (keystroke → menu action). +//! * [`actions`] — the action registry: gpui actions, stable ids, default +//! keys and routing (the single source behind the menus and shortcuts). //! * [`manager`] — the project manager window (M13 D4): the library browser //! with new / open / rename / duplicate / delete / import / export. +//! * [`menus`] — the right-click menu layer: shared segments built from the +//! action registry and the per-panel context-menu plumbing. //! * [`panels`] — the dockable panels (viewers, timeline, inspector, ...). //! * [`oakui`] — the engine gateway trait, the mock + real implementations, //! and the pure view-state logic (timecode, transport). @@ -53,13 +56,14 @@ //! the mock with the `--mock` flag, `OAK_ENGINE=mock`, or the //! `mock-engine` cargo feature. +pub mod actions; pub mod app; pub mod dialogs; pub mod i18n; pub mod manager; +pub mod menus; pub mod oakui; pub mod panels; -pub mod shortcuts; /// The application entry point (called from `main.rs`). pub fn run() { diff --git a/src/menus/context.rs b/src/menus/context.rs new file mode 100644 index 000000000..a10fc309e --- /dev/null +++ b/src/menus/context.rs @@ -0,0 +1,81 @@ +// 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 context-menu plumbing every panel shares: each panel owns one +//! [`ContextMenuHandle`], which wraps the +//! [`ContextMenu`](gpui_widgets::menu::ContextMenu) popup entity and splits +//! its item activations in two — items whose id belongs to the action +//! registry ([`crate::actions::entry_for_menu_id`]) are re-emitted as +//! [`ContextMenuTriggered`] so the app shell routes them through the same +//! dispatch path the menu bar uses, and everything else (the local ids from +//! [`super::shared::LOCAL_ID_BASE`] up) goes to the panel's own handler. +//! This is the Rust counterpart of the C++ panels wiring shared +//! `MenuShared` actions and widget-local slots into one `QMenu`. + +use gpui::{App, AppContext, Context, Entity, EventEmitter, Pixels, Point, Window}; +use gpui_widgets::menu::{ContextMenu, ContextMenuEvent, Menu}; + +/// A registry-backed context-menu item was triggered: the panel re-emits it +/// so the app shell dispatches it like a menu-bar click (after pointing +/// `focused_panel` at the panel, since a right-click does not emit +/// `PanelEvent::Focused`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ContextMenuTriggered { + /// The triggered item's id (an action registry menu id). + pub item: usize, +} + +/// A panel's context menu: owns the popup entity, re-emits registry items as +/// [`ContextMenuTriggered`] and hands local items to the panel. +pub struct ContextMenuHandle { + menu: Entity, +} + +impl ContextMenuHandle { + /// Create the popup entity and subscribe to it. `on_local_item` handles + /// every triggered item that is not in the action registry (color + /// labels, panel-specific placeholders, …). + pub fn new(on_local_item: F, window: &mut Window, cx: &mut Context

) -> Self + where + P: EventEmitter, + F: Fn(&mut P, usize, &mut Context

) + 'static, + { + let menu = cx.new(|cx| ContextMenu::new(0, window, cx)); + cx.subscribe( + &menu, + move |panel: &mut P, _menu, event: &ContextMenuEvent, cx| { + if crate::actions::entry_for_menu_id(event.item).is_some() { + cx.emit(ContextMenuTriggered { item: event.item }); + } else { + on_local_item(panel, event.item, cx); + } + }, + ) + .detach(); + Self { menu } + } + + /// Open the menu at `position` (window coordinates). + pub fn show(&self, position: Point, menu: Menu, cx: &mut App) { + self.menu.update(cx, |menu_view, cx| menu_view.show(position, menu, cx)); + } + + /// The popup entity, to be rendered as a child of the panel so the + /// anchored popup can paint above it. + pub fn widget(&self) -> Entity { + self.menu.clone() + } +} diff --git a/src/menus/mod.rs b/src/menus/mod.rs new file mode 100644 index 000000000..dfc975b2b --- /dev/null +++ b/src/menus/mod.rs @@ -0,0 +1,33 @@ +// 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 right-click menu layer: the Rust counterpart of the C++ context-menu +//! system (`MenuShared` segments + the per-widget `show_context_menu` +//! methods, `app/widget/menu/menushared.cpp` and friends). +//! +//! * [`context`] — the shared plumbing every panel needs to own a +//! [`ContextMenu`](gpui_widgets::menu::ContextMenu): entity creation, +//! event subscription and show/hide, plus the +//! [`ContextMenuTriggered`](context::ContextMenuTriggered) event the +//! panels emit so the shell routes registry items through the same +//! dispatch path the menu bar uses. +//! * [`shared`] — the shared menu segments (edit / clip-edit / in-out / +//! color label / new), built from the action registry +//! ([`crate::actions`]) so ids, labels and shortcut annotations can never +//! diverge from the menu bar. + +pub mod context; +pub mod shared; diff --git a/src/menus/shared.rs b/src/menus/shared.rs new file mode 100644 index 000000000..f66bc0297 --- /dev/null +++ b/src/menus/shared.rs @@ -0,0 +1,411 @@ +// 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 shared context-menu segments: the Rust counterpart of the C++ +//! `MenuShared` item groups (`add_items_for_edit_menu`, +//! `add_items_for_clip_edit_menu`, `add_items_for_in_out_menu`, +//! `add_items_for_new_menu` and the `ColorLabelMenu`, +//! `app/widget/menu/menushared.cpp` + `app/widget/colorlabelmenu/`). +//! +//! Registry-backed items are built from [`crate::actions`] exactly like the +//! menu bar, so ids, labels and shortcut annotations can never diverge. +//! Local (non-registry) items — the 16 color labels today — live at +//! [`LOCAL_ID_BASE`] and above; [`crate::actions::entry_for_menu_id`] is +//! what splits the two worlds at dispatch time. + +use gpui_widgets::menu::{Menu, MenuItem}; + +use crate::actions::ActionId; + +/// The first id reserved for local (non-registry) context-menu items. Every +/// registry action's menu id sits below this; the panels' panel-specific +/// items and the color labels sit at or above it. +pub const LOCAL_ID_BASE: usize = 2001; + +/// The first of the 16 color-label items: +/// `COLOR_LABEL_BASE..COLOR_LABEL_BASE + COLOR_LABEL_COUNT`. +pub const COLOR_LABEL_BASE: usize = LOCAL_ID_BASE; + +/// The number of standard color labels (the C++ `ColorCoding` enum: red … +/// gray). +pub const COLOR_LABEL_COUNT: usize = 16; + +/// One menu item straight from the registry: id and label come from the +/// entry, the shortcut annotation from +/// [`display_shortcut`](crate::actions::display_shortcut) — the same recipe +/// the menu bar uses. +pub fn action_item(action: ActionId) -> MenuItem { + let entry = action.entry(); + let mut item = MenuItem::new(entry.menu_id(), crate::i18n::tr(entry.i18n_key)); + if let Some(shortcut) = crate::actions::display_shortcut(action) { + item = item.with_shortcut(shortcut); + } + item +} + +/// The shared "edit" segment (`add_items_for_edit_menu`): undo/redo, the +/// clipboard group and delete; with `for_clips` the clip-only tail follows +/// (ripple delete, split, speed/duration, then the clip-edit group). +pub fn edit_section(for_clips: bool) -> Vec { + use ActionId as A; + let mut items = vec![ + action_item(A::Undo), + action_item(A::Redo).separated(), + action_item(A::Cut), + action_item(A::Copy), + action_item(A::Paste), + action_item(A::PasteInsert), + action_item(A::Duplicate), + action_item(A::Rename), + action_item(A::Delete), + ]; + if for_clips { + items.push(action_item(A::RippleDelete)); + items.push(action_item(A::SplitAtPlayhead)); + items.push(action_item(A::SpeedDuration).separated()); + items.extend(clip_edit_section()); + } + items +} + +/// The shared "clip edit" segment (`add_items_for_clip_edit_menu`): default +/// transition, link/unlink, enable/disable, nest. +pub fn clip_edit_section() -> Vec { + use ActionId as A; + vec![ + action_item(A::DefaultTransition), + action_item(A::LinkUnlink), + action_item(A::EnableDisable), + action_item(A::Nest), + ] +} + +/// The shared "in/out" segment (`add_items_for_in_out_menu`). +pub fn in_out_section() -> Vec { + use ActionId as A; + vec![ + action_item(A::SetInPoint), + action_item(A::SetOutPoint).separated(), + action_item(A::ResetIn), + action_item(A::ResetOut), + action_item(A::ClearInOut), + ] +} + +/// The shared "new" segment (`add_items_for_new_menu`). +pub fn new_section() -> Vec { + use ActionId as A; + vec![ + action_item(A::NewProject).separated(), + action_item(A::NewSequence), + action_item(A::NewFolder), + ] +} + +/// The 16 color labels as a "Color" submenu (the C++ `ColorLabelMenu`), +/// with `selected` checked when it is the item's index +/// (`0..COLOR_LABEL_COUNT`). +pub fn color_label_menu(selected: Option) -> Menu { + const KEYS: [&str; COLOR_LABEL_COUNT] = [ + "menu.color.red", + "menu.color.maroon", + "menu.color.orange", + "menu.color.brown", + "menu.color.yellow", + "menu.color.oak", + "menu.color.lime", + "menu.color.green", + "menu.color.cyan", + "menu.color.teal", + "menu.color.blue", + "menu.color.navy", + "menu.color.pink", + "menu.color.purple", + "menu.color.silver", + "menu.color.gray", + ]; + let items = KEYS + .iter() + .enumerate() + .map(|(index, key)| { + let mut item = MenuItem::new(COLOR_LABEL_BASE + index, crate::i18n::tr(key)); + if selected == Some(index) { + item = item.with_checked(true); + } + item + }) + .collect(); + Menu::new(items) +} + +/// The "Color" submenu header item (label localized, submenu attached). +pub fn color_label_item(selected: Option) -> MenuItem { + MenuItem::new(0, crate::i18n::tr("menu.color.label")).with_submenu(color_label_menu(selected)) +} + +/// The color index (`0..COLOR_LABEL_COUNT`) behind a triggered menu item id, +/// when `item` is one of the color-label items. +pub fn color_label_index(item: usize) -> Option { + (item >= COLOR_LABEL_BASE && item < COLOR_LABEL_BASE + COLOR_LABEL_COUNT) + .then(|| item - COLOR_LABEL_BASE) +} + +// --------------------------------------------------------------------------- +// Viewer context menu (shared by the source and program monitors) +// --------------------------------------------------------------------------- + +/// Local (non-registry) item ids of the viewer context menu. +pub const LOCAL_VIEWER_ZOOM_FIT: usize = 2301; +/// The zoom-level items occupy `LOCAL_VIEWER_ZOOM_LEVELS_BASE + i`, aligned +/// with [`VIEWER_ZOOM_LEVELS`]. +pub const LOCAL_VIEWER_ZOOM_LEVELS_BASE: usize = 2302; +pub const LOCAL_VIEWER_FULL_SCREEN: usize = 2320; +pub const LOCAL_VIEWER_RES_FULL: usize = 2321; +pub const LOCAL_VIEWER_RES_HALF: usize = 2322; +pub const LOCAL_VIEWER_RES_QUARTER: usize = 2323; +pub const LOCAL_VIEWER_RES_EIGHTH: usize = 2324; +pub const LOCAL_VIEWER_SAFE_OFF: usize = 2325; +pub const LOCAL_VIEWER_SAFE_ON: usize = 2326; +pub const LOCAL_VIEWER_SAFE_CUSTOM: usize = 2327; +pub const LOCAL_VIEWER_STOP_ON_LAST: usize = 2328; +pub const LOCAL_VIEWER_WF_AUTOMATIC: usize = 2329; +pub const LOCAL_VIEWER_WF_ONLY: usize = 2330; +pub const LOCAL_VIEWER_WF_BOTH: usize = 2331; +pub const LOCAL_VIEWER_SHOW_FPS: usize = 2332; +pub const LOCAL_VIEWER_SAVE_FRAME: usize = 2333; + +/// The zoom levels the viewer's Zoom submenu offers (the C++ +/// `ViewerSizer::k_zoom_levels`). +pub const VIEWER_ZOOM_LEVELS: [f32; 10] = + [0.05, 0.1, 0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 4.0, 8.0]; + +/// The zoom submenu item id for `level` (one of [`VIEWER_ZOOM_LEVELS`]). +pub fn viewer_zoom_level_id(index: usize) -> usize { + LOCAL_VIEWER_ZOOM_LEVELS_BASE + index +} + +/// The context menu both viewer monitors show (the C++ +/// `ViewerWidget::show_context_menu`, minus the OCIO color menus and the +/// subtitle block the engine does not surface yet). Zoom / playback +/// resolution / safe margins / waveform / FPS are placeholders until the +/// viewer widget grows those controls. +pub fn viewer_menu() -> Menu { + use crate::i18n::tr; + // Zoom: Fit + one entry per zoom level. + let mut zoom_items = vec![MenuItem::new(LOCAL_VIEWER_ZOOM_FIT, tr("viewer.context.zoom_fit"))]; + for (index, level) in VIEWER_ZOOM_LEVELS.iter().enumerate() { + zoom_items.push(MenuItem::new( + viewer_zoom_level_id(index), + format!("{:.0}%", level * 100.0), + )); + } + // Playback resolution radio group. + let resolution_menu = Menu::new(vec![ + MenuItem::new(LOCAL_VIEWER_RES_FULL, tr("viewer.context.res_full")).with_checked(true), + MenuItem::new(LOCAL_VIEWER_RES_HALF, tr("viewer.context.res_half")).with_checked(false), + MenuItem::new(LOCAL_VIEWER_RES_QUARTER, tr("viewer.context.res_quarter")) + .with_checked(false), + MenuItem::new(LOCAL_VIEWER_RES_EIGHTH, tr("viewer.context.res_eighth")).with_checked(false), + ]); + // Safe margins radio group. + let safe_menu = Menu::new(vec![ + MenuItem::new(LOCAL_VIEWER_SAFE_OFF, tr("viewer.context.safe_off")).with_checked(true), + MenuItem::new(LOCAL_VIEWER_SAFE_ON, tr("viewer.context.safe_on")).with_checked(false), + MenuItem::new(LOCAL_VIEWER_SAFE_CUSTOM, tr("viewer.context.safe_custom")) + .with_checked(false), + ]); + // Audio waveform radio group. + let waveform_menu = Menu::new(vec![ + MenuItem::new(LOCAL_VIEWER_WF_AUTOMATIC, tr("viewer.context.wf_automatic")) + .with_checked(true), + MenuItem::new(LOCAL_VIEWER_WF_ONLY, tr("viewer.context.wf_only")).with_checked(false), + MenuItem::new(LOCAL_VIEWER_WF_BOTH, tr("viewer.context.wf_both")).with_checked(false), + ]); + + Menu::new(vec![ + MenuItem::new(0, tr("viewer.context.zoom")).with_submenu(Menu::new(zoom_items)), + MenuItem::new(LOCAL_VIEWER_FULL_SCREEN, tr("viewer.context.full_screen")), + MenuItem::new(0, tr("viewer.context.playback_resolution")) + .with_submenu(resolution_menu), + MenuItem::new(0, tr("viewer.context.safe_margins")).with_submenu(safe_menu).separated(), + MenuItem::new(LOCAL_VIEWER_STOP_ON_LAST, tr("viewer.context.stop_on_last")) + .with_checked(false) + .separated(), + MenuItem::new(0, tr("viewer.context.audio_waveform")).with_submenu(waveform_menu), + MenuItem::new(LOCAL_VIEWER_SHOW_FPS, tr("viewer.context.show_fps")).with_checked(false), + MenuItem::new(LOCAL_VIEWER_SAVE_FRAME, tr("viewer.context.save_frame")).separated(), + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The color-label ids occupy the first local-id block. + #[test] + fn color_label_ids_are_the_first_local_ids() { + assert_eq!(COLOR_LABEL_BASE, LOCAL_ID_BASE); + for index in 0..COLOR_LABEL_COUNT { + assert_eq!(color_label_index(COLOR_LABEL_BASE + index), Some(index)); + } + assert_eq!(color_label_index(LOCAL_ID_BASE - 1), None); + assert_eq!(color_label_index(LOCAL_ID_BASE + COLOR_LABEL_COUNT), None); + } + + /// The color submenu carries all 16 labels with registry-free ids and + /// checks only the selected one. + #[test] + fn color_label_menu_marks_the_selection() { + let menu = color_label_menu(Some(5)); + assert_eq!(menu.items.len(), COLOR_LABEL_COUNT); + for (index, item) in menu.items.iter().enumerate() { + assert_eq!(item.id, COLOR_LABEL_BASE + index); + assert!(crate::actions::entry_for_menu_id(item.id).is_none()); + assert_eq!(item.checked, (index == 5).then_some(true)); + } + } + + /// The edit segment mirrors `add_items_for_edit_menu`: the plain form + /// ends at delete, the clip form appends the clip-only tail. + #[test] + fn edit_section_matches_the_cpp_layout() { + use ActionId as A; + let plain = edit_section(false); + let ids: Vec = plain.iter().map(|item| item.id).collect(); + assert_eq!( + ids, + vec![ + A::Undo.menu_id(), + A::Redo.menu_id(), + A::Cut.menu_id(), + A::Copy.menu_id(), + A::Paste.menu_id(), + A::PasteInsert.menu_id(), + A::Duplicate.menu_id(), + A::Rename.menu_id(), + A::Delete.menu_id(), + ] + ); + // The separator sits after redo. + assert!(plain[1].separator_after); + + let clips = edit_section(true); + let tail: Vec = clips.iter().skip(9).map(|item| item.id).collect(); + assert_eq!( + tail, + vec![ + A::RippleDelete.menu_id(), + A::SplitAtPlayhead.menu_id(), + A::SpeedDuration.menu_id(), + A::DefaultTransition.menu_id(), + A::LinkUnlink.menu_id(), + A::EnableDisable.menu_id(), + A::Nest.menu_id(), + ] + ); + // The separator between the clip-only editing and clip-edit groups + // sits after speed/duration. + assert!(clips[11].separator_after); + } + + /// The in/out and new segments keep the C++ separator placement. + #[test] + fn in_out_and_new_sections_match_the_cpp_layout() { + use ActionId as A; + let in_out = in_out_section(); + assert_eq!( + in_out.iter().map(|item| item.id).collect::>(), + vec![ + A::SetInPoint.menu_id(), + A::SetOutPoint.menu_id(), + A::ResetIn.menu_id(), + A::ResetOut.menu_id(), + A::ClearInOut.menu_id(), + ] + ); + assert!(in_out[1].separator_after); + + let new_menu = new_section(); + assert_eq!( + new_menu.iter().map(|item| item.id).collect::>(), + vec![ + A::NewProject.menu_id(), + A::NewSequence.menu_id(), + A::NewFolder.menu_id(), + ] + ); + assert!(new_menu[0].separator_after); + } + + /// Every registry item the segments build resolves back to its action. + #[test] + fn segment_items_resolve_to_registry_entries() { + for item in edit_section(true) + .into_iter() + .chain(in_out_section()) + .chain(new_section()) + { + assert!( + crate::actions::entry_for_menu_id(item.id).is_some(), + "segment item {} is not a registry id", + item.id + ); + } + } + + /// The viewer menu carries the zoom levels with percentage labels and + /// defaults each radio group to its first entry. + #[test] + fn viewer_menu_offers_every_zoom_level() { + let menu = viewer_menu(); + let zoom = menu + .items + .iter() + .find(|item| item.label == crate::i18n::tr("viewer.context.zoom")) + .expect("zoom submenu"); + let zoom_items = &zoom.submenu.as_ref().unwrap().items; + assert_eq!(zoom_items.len(), 1 + VIEWER_ZOOM_LEVELS.len()); + assert_eq!(zoom_items[0].id, LOCAL_VIEWER_ZOOM_FIT); + for (index, level) in VIEWER_ZOOM_LEVELS.iter().enumerate() { + assert_eq!(zoom_items[index + 1].id, viewer_zoom_level_id(index)); + assert_eq!(zoom_items[index + 1].label, format!("{:.0}%", level * 100.0)); + } + } + + /// The resolution / safe-margin / waveform submenus check their first + /// (default) entry only. + #[test] + fn viewer_menu_radio_groups_default_to_the_first_entry() { + let menu = viewer_menu(); + for label_key in [ + "viewer.context.playback_resolution", + "viewer.context.safe_margins", + "viewer.context.audio_waveform", + ] { + let item = menu + .items + .iter() + .find(|item| item.label == crate::i18n::tr(label_key)) + .unwrap_or_else(|| panic!("viewer menu missing {label_key}")); + let sub = item.submenu.as_ref().unwrap(); + assert_eq!(sub.items[0].checked, Some(true), "{label_key} default"); + assert!( + sub.items[1..].iter().all(|item| item.checked == Some(false)), + "{label_key} non-defaults unchecked" + ); + } + } +} diff --git a/src/oakui/engine.rs b/src/oakui/engine.rs index 697644955..3045ad9ff 100644 --- a/src/oakui/engine.rs +++ b/src/oakui/engine.rs @@ -37,8 +37,10 @@ use std::sync::Arc; use gpui::effect_stack::{EffectStackDataSource, EffectStackEvent}; use gpui::node_graph::{NodeGraphDataSource, NodeGraphEvent}; -use gpui::timeline::{ClipId, Frame, FrameRate, TimelineDataSource, TimelineEvent, TrackKind}; -use gpui::{App, Context, Entity, Pixels, RenderImage}; +use gpui::timeline::{ + ClipId, Frame, FrameRate, TimelineDataSource, TimelineEvent, TrackData, TrackKind, +}; +use gpui::{App, Context, Entity, Pixels, Point, RenderImage}; use gpui_widgets::audio_meter::AudioMeterDataSource; use gpui_widgets::project_explorer::ProjectDataSource; use gpui_widgets::viewer::PlaybackClock; @@ -124,6 +126,56 @@ pub struct Sequence { pub length: Frame, } +/// One row of the undo history (the C++ `HistoryModel` row): a command +/// label plus whether the command is currently done (undone rows render +/// gray in the history panel). +#[derive(Debug, Clone, PartialEq)] +pub struct HistoryEntry { + /// The command's user-visible label (may be empty; the panel falls + /// back to a generic "Command" like the C++ widget). + pub name: String, + /// Whether the command is currently done (`false` = the redoable + /// tail). + pub done: bool, +} + +/// One creatable node type the node editor's "Add" submenu lists — the +/// Rust counterpart of a C++ `NodeFactory` menu entry. `category_key` is +/// the i18n key of the category submenu the entry belongs to (the first +/// of the factory entry's categories; entries whose only category never +/// appears in the create menu are skipped). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NodeLibraryEntry { + /// The factory type id handed to [`AppEngine::add_node_at`]. + pub type_id: String, + /// The node's display name. + pub name: String, + /// The i18n key of the category submenu (`node.category.*`). + pub category_key: &'static str, +} + +/// The i18n key of a node category submenu, or `None` for categories that +/// never appear in the node editor's Add menu (timeline-structural nodes). +pub fn node_category_key(category: oaknode::node::Category) -> Option<&'static str> { + use oaknode::node::Category as C; + Some(match category { + C::Output => "node.category.output", + C::Effect => "node.category.effect", + C::Generator => "node.category.generator", + C::Input => "node.category.input", + C::Math => "node.category.math", + C::Color => "node.category.color", + C::Distort => "node.category.distort", + C::Filter => "node.category.filter", + C::Keying => "node.category.keying", + C::OpenFx => "node.category.openfx", + C::Group => "node.category.group", + // Tracks/blocks are timeline-structural; the user never creates + // them from the node editor. + C::Timeline => return None, + }) +} + /// The engine gateway. /// /// Implementations own the "engine" side of the app: project state, the @@ -278,6 +330,106 @@ pub trait AppEngine: /// tool's menu action). fn split_at_playhead(&mut self, cx: &mut Context); + // ------------------------------------------------------------------- + // Right-click menu support: the node editor's Add menu, the track-head + // "delete all empty tracks" action, and the project explorer's footage + // operations. Defaults degrade to "unsupported" / "unknown". + // ------------------------------------------------------------------- + + /// The creatable node types the node editor's Add submenu lists (the + /// C++ `NodeFactory` menu listing): the global factory's entries minus + /// the ones flagged `dont_show_in_create_menu`, each tagged with its + /// first category's i18n key. Runtime-registered (plugin) entries are + /// included. + fn node_library(&self) -> Vec { + let mut out = Vec::new(); + let factory = oaknode::factory::Factory::global(); + for meta in factory.entries() { + // A scratch instance per entry just to read its flags (the + // factory metadata carries no flag copy). + let (core, _behavior) = (meta.create)(); + if core.flags & oaknode::node::flags::DONT_SHOW_IN_CREATE_MENU != 0 { + continue; + } + let Some(category_key) = meta.categories.first().copied().and_then(node_category_key) + else { + continue; + }; + let name = if meta.name.is_empty() { + meta.type_id.to_string() + } else { + meta.name.to_string() + }; + out.push(NodeLibraryEntry { + type_id: meta.type_id.to_string(), + name, + category_key, + }); + } + for meta in factory.dynamic_entries() { + let Some(category_key) = meta.categories.first().copied().and_then(node_category_key) + else { + continue; + }; + out.push(NodeLibraryEntry { + type_id: meta.type_id, + name: meta.name, + category_key, + }); + } + out + } + + /// Creates a node of type `type_id` at `position` (graph space) in the + /// current sequence's node graph — the node editor's Add menu action. + /// Default: unsupported. + fn add_node_at( + &mut self, + type_id: &str, + position: Point, + cx: &mut Context, + ) -> Result<(), String> { + let _ = (type_id, position, cx); + Err("add node not supported".into()) + } + + /// Removes every clip-less track through + /// [`remove_track`](Self::remove_track), so each removal keeps the + /// backend's undo packaging (the C++ asks for confirmation first; this + /// port does not — a known deviation). + fn delete_empty_tracks(&mut self, cx: &mut Context) { + let budget = self.track_count(); + for _ in 0..budget { + let Some(index) = (0..self.track_count()) + .find(|index| self.track(*index).is_some_and(|track| track.clips().is_empty())) + else { + break; + }; + self.remove_track(index, cx); + } + } + + /// The on-disk path of the footage behind project-explorer entry `id` + /// (enables "Reveal in Finder" / drives "Replace Footage"), if the + /// backend knows it. Default: unknown. + fn entry_path(&self, id: u64) -> Option { + let _ = id; + None + } + + /// Replaces the footage of project-explorer entry `id` with the media + /// file at `path` (the C++ `ReplaceFootage` flow). Default: + /// unsupported. + fn replace_footage( + &mut self, + id: u64, + path: PathBuf, + cx: &mut Context, + ) -> Result<(), String> { + let _ = (id, path, cx); + Err("replace footage not supported".into()) + } + // ------------------------------------------------------------------- // Sequence markers & work area (M12 P4): the facade surfaces are // undoable, mirroring Olive (MarkerAdd/MarkerRemove/WorkareaSet*). @@ -340,6 +492,27 @@ pub trait AppEngine: /// Steps the undo stack forward one entry. fn redo(&mut self, cx: &mut Context); + /// The undo-stack rows the history panel lists (every command, done + /// first then the redoable tail; the C++ `HistoryModel` order). + /// Default: no history (the mock keeps no undo stack). + fn history_entries(&self) -> Vec { + Vec::new() + } + + /// The current stack position (done-command count); the history panel + /// selects row `index - 1` and grays rows at/after `index`. + /// Default: 0. + fn history_index(&self) -> i64 { + 0 + } + + /// Undo/redo until the done-command count equals `index` (a history + /// panel row click jumps to `row + 1`, the C++ `HistoryWidget` + /// behavior). Default: no-op. + fn jump_history(&mut self, index: i64, cx: &mut Context) { + let _ = (index, cx); + } + /// Starts a new blank project with a single default sequence. fn new_project(&mut self, cx: &mut Context); diff --git a/src/oakui/mock.rs b/src/oakui/mock.rs index d4dca39ff..2278288c0 100644 --- a/src/oakui/mock.rs +++ b/src/oakui/mock.rs @@ -479,6 +479,10 @@ pub struct MockEngine { edges: Vec, /// Id allocator for edges added at runtime. next_edge_id: u64, + /// Id allocator for nodes added at runtime (the node editor's Add menu). + next_node_id: u64, + /// Id allocator for the ports of nodes added at runtime. + next_port_id: u64, /// The node selection, kept in sync with the node editor (and, later, the /// effect stack) so both views share one selection. node_selection: BTreeSet, @@ -759,6 +763,8 @@ impl MockEngine { nodes, edges, next_edge_id: 6, + next_node_id: 100, + next_port_id: 1000, node_selection: BTreeSet::new(), cpu_frame_cache: Mutex::new(HashMap::new()), imported_footage: Vec::new(), @@ -848,7 +854,9 @@ impl MockEngine { /// requests" loop: the view emits, the engine applies and notifies). pub fn apply_node_graph_event(&mut self, event: &NodeGraphEvent, cx: &mut Context) { match event { - NodeGraphEvent::NodeMovePreview { .. } | NodeGraphEvent::ViewChanged { .. } => {} + NodeGraphEvent::NodeMovePreview { .. } + | NodeGraphEvent::ViewChanged { .. } + | NodeGraphEvent::NodeContextMenuRequested { .. } => {} NodeGraphEvent::NodeMoveRequested { nodes, delta } => { for id in nodes { if let Some(node) = self.nodes.iter_mut().find(|n| n.id() == *id) { @@ -1240,6 +1248,50 @@ impl AppEngine for MockEngine { Ok(()) } + fn add_node_at( + &mut self, + type_id: &str, + position: Point, + cx: &mut Context, + ) -> Result<(), String> { + // The demo graph is display-only; a created node gets one video + // input and one video output, titled after the factory entry. + let library = self.node_library(); + let Some(entry) = library.iter().find(|entry| entry.type_id == type_id) else { + return Err(format!("unknown node type \"{type_id}\"")); + }; + let video_type = PortDataType::new("video", hsla(0.55, 0.75, 0.6, 1.0)); + let in_id = PortId(self.next_port_id); + let out_id = PortId(self.next_port_id + 1); + self.next_port_id += 2; + let node = MockNode { + id: NodeId(self.next_node_id), + title: entry.name.clone().into(), + position, + inputs: vec![MockPort { + id: in_id, + kind: PortKind::Input, + label: "in".into(), + data_type: video_type.clone(), + connected: false, + }], + outputs: vec![MockPort { + id: out_id, + kind: PortKind::Output, + label: "out".into(), + data_type: video_type, + connected: false, + }], + header_color: None, + enabled: true, + collapsed: false, + }; + self.next_node_id += 1; + self.nodes.push(node); + cx.notify(); + Ok(()) + } + fn apply_node_graph_event(&mut self, event: &NodeGraphEvent, cx: &mut Context) { self.apply_node_graph_event(event, cx); } @@ -1305,6 +1357,7 @@ impl AppEngine for MockEngine { TimelineEvent::SelectionChanged | TimelineEvent::TrackSelected { .. } | TimelineEvent::TransitionChanged { .. } + | TimelineEvent::ContextMenuRequested { .. } | TimelineEvent::ZoomChanged(_) => {} TimelineEvent::TrackToggleRequested { track, toggle } => { // The demo model applies the toggles directly (no undo in diff --git a/src/oakui/mod.rs b/src/oakui/mod.rs index 593a4e57e..ccf24ba08 100644 --- a/src/oakui/mod.rs +++ b/src/oakui/mod.rs @@ -56,8 +56,8 @@ pub mod transport; pub mod waveform; pub use engine::{ - AppEngine, EngineClock, EngineGateway, ExportEvent, ExportSession, LibraryProject, Monitor, - Project, ScopeData, Sequence, VideoFormat, + AppEngine, EngineClock, EngineGateway, ExportEvent, ExportSession, HistoryEntry, + LibraryProject, Monitor, NodeLibraryEntry, Project, ScopeData, Sequence, VideoFormat, }; pub use mock::{MockClock, MockEngine}; pub use real::{RealClock, RealEngine}; diff --git a/src/oakui/real.rs b/src/oakui/real.rs index e79987983..fb7ad83ce 100644 --- a/src/oakui/real.rs +++ b/src/oakui/real.rs @@ -1283,6 +1283,17 @@ impl RealEngine { } } + /// The shared refresh after any undo-stack change (undo / redo / + /// history jump): the sequence info, the timeline snapshot and the + /// frame caches all follow the reverted or re-applied state. + fn apply_stack_change(&mut self, cx: &mut Context) { + self.refresh_sequence_info(); + self.rebuild_timeline(); + self.cpu_frame_cache.lock().unwrap().clear(); + self.full_res_generation = self.full_res_generation.wrapping_add(1); + cx.notify(); + } + /// Snapshots one track (with its clips) from the graph. fn snapshot_track( graph: &oaknode::graph::Graph, @@ -1924,7 +1935,10 @@ impl AppEngine for RealEngine { NodeGraphEvent::NodeMovePreview { .. } | NodeGraphEvent::ViewChanged { .. } | NodeGraphEvent::BackgroundClicked { .. } - | NodeGraphEvent::SelectionChanged { .. } => {} + | NodeGraphEvent::SelectionChanged { .. } + // The node editor panel answers the right-click itself (it owns + // the popup); the engine has nothing to apply. + | NodeGraphEvent::NodeContextMenuRequested { .. } => {} _ => { if let (Some(project), Some(seq)) = (self.project.clone(), self.sequence) { let result = crate::oakui::nodegraph::apply_edit(&project, seq, event); @@ -2022,10 +2036,12 @@ impl AppEngine for RealEngine { } cx.notify(); } - // Selection / zoom / transition / track-selected: not editable. + // Selection / zoom / transition / track-selected / context-menu: + // not editable (the right-click is answered by the panel's popup). TimelineEvent::SelectionChanged | TimelineEvent::TrackSelected { .. } | TimelineEvent::TransitionChanged { .. } + | TimelineEvent::ContextMenuRequested { .. } | TimelineEvent::ZoomChanged(_) => {} TimelineEvent::TrackToggleRequested { track, toggle } => { // The header toggles map onto the undoable track flag @@ -2237,22 +2253,45 @@ impl AppEngine for RealEngine { fn undo(&mut self, cx: &mut Context) { if self.project.is_some() { oakundo::global::undo().ok(); - self.refresh_sequence_info(); - self.rebuild_timeline(); - self.cpu_frame_cache.lock().unwrap().clear(); - self.full_res_generation = self.full_res_generation.wrapping_add(1); - cx.notify(); + self.apply_stack_change(cx); } } fn redo(&mut self, cx: &mut Context) { if self.project.is_some() { oakundo::global::redo().ok(); - self.refresh_sequence_info(); - self.rebuild_timeline(); - self.cpu_frame_cache.lock().unwrap().clear(); - self.full_res_generation = self.full_res_generation.wrapping_add(1); - cx.notify(); + self.apply_stack_change(cx); + } + } + + fn history_entries(&self) -> Vec { + if self.project.is_none() { + return Vec::new(); + } + // The C++ `HistoryModel` lists every row of the engine stack (done + // first, then the redoable tail); labels are read through the same + // two-stage contract the C++ `oakengine_undo_command_text` uses. + let count = oakundo::global::count().unwrap_or(0); + (0..count) + .map(|row| super::HistoryEntry { + name: oakundo::global::command_name(row).unwrap_or_default(), + done: oakundo::global::command_done(row).unwrap_or(false), + }) + .collect() + } + + fn history_index(&self) -> i64 { + if self.project.is_some() { + oakundo::global::index().unwrap_or(0) + } else { + 0 + } + } + + fn jump_history(&mut self, index: i64, cx: &mut Context) { + if self.project.is_some() { + oakundo::global::jump(index).ok(); + self.apply_stack_change(cx); } } @@ -2306,6 +2345,59 @@ impl AppEngine for RealEngine { Ok(()) } + fn entry_path(&self, id: u64) -> Option { + let project = self.project.clone()?; + let footage = graphops::id_of(id)?; + let guard = graphops::lock(&project); + if !guard.graph.is_valid(footage) { + return None; + } + let behavior = graphops::footage_behavior(&guard.graph, footage)?; + if behavior.filename.is_empty() { + return None; + } + Some(PathBuf::from(&behavior.filename)) + } + + fn replace_footage( + &mut self, + id: u64, + path: PathBuf, + cx: &mut Context, + ) -> Result<(), String> { + if !path.is_file() { + return Err(format!("file does not exist: {}", path.display())); + } + let Some(project) = self.project.clone() else { + return Err("no project open".into()); + }; + let Some(footage) = graphops::id_of(id) else { + return Err("unknown project entry".into()); + }; + { + let mut guard = graphops::lock(&project); + if !guard.graph.is_valid(footage) { + return Err("unknown project entry".into()); + } + let Some(f) = guard + .graph + .get_mut(footage) + .and_then(|e| e.behavior.as_any_mut()) + .and_then(|a| a.downcast_mut::()) + else { + return Err("entry is not footage".into()); + }; + f.filename = path.to_string_lossy().into_owned(); + // Re-probe in place; a failed probe leaves the footage invalid, + // matching the import-time rejection behavior. (Not undoable + // yet — the C++ replace is a single command.) + f.probe() + .map_err(|e| format!("failed to probe \"{}\": {e}", path.display()))?; + } + cx.notify(); + Ok(()) + } + fn drop_footage( &mut self, id: u64, @@ -3356,6 +3448,60 @@ mod tests { assert!(cx.read(|app| engine.read(app).track(0).expect("V1").is_visible())); } + /// The history panel's engine surface mirrors the C++ `HistoryWidget` + /// over the real stack: rows track every pushed command, `done` flags + /// gray the redoable tail, and `jump_history` walks the stack both + /// ways (a row click's `row + 1` target). + #[gpui::test] + async fn real_engine_history_tracks_the_undo_stack(cx: &mut gpui::TestAppContext) { + let _media = media_lock(); + let engine = cx.update(|cx| cx.new(|cx| RealEngine::create(cx))); + cx.update(|app| engine.update(app, |engine, cx| engine.new_project(cx))); + + // A fresh project leaves the bottom "New/Open Project" command on + // the stack: one row, the pointer at 1. + let base = cx.read(|app| engine.read(app).history_entries().len()); + assert!(base >= 1, "the stack always lists the bottom command"); + assert_eq!(cx.read(|app| engine.read(app).history_index()), base as i64); + + // Two undoable edits add two labeled done rows. + cx.update(|app| engine.update(app, |engine, cx| engine.add_track(TrackKind::Video, cx))); + cx.update(|app| engine.update(app, |engine, cx| engine.add_track(TrackKind::Audio, cx))); + let entries = cx.read(|app| engine.read(app).history_entries()); + assert_eq!(entries.len(), base + 2); + assert!(entries.iter().all(|e| e.done), "fresh rows are done"); + assert!( + entries[base].name.is_empty() == false && entries[base + 1].name.is_empty() == false, + "edit rows carry their command labels" + ); + assert_eq!(cx.read(|app| engine.read(app).history_index()), (base + 2) as i64); + + // Undo grays the newest row (it joins the redoable tail). + cx.update(|app| engine.update(app, |engine, cx| engine.undo(cx))); + let entries = cx.read(|app| engine.read(app).history_entries()); + assert!(!entries.last().unwrap().done, "undone row stays listed"); + assert_eq!(cx.read(|app| engine.read(app).history_index()), (base + 1) as i64); + + // A jump to the bottom undoes everything below the base command; + // the rows stay listed (gray), matching the C++ jump semantics. + cx.update(|app| engine.update(app, |engine, cx| engine.jump_history(base as i64, cx))); + assert_eq!(cx.read(|app| engine.read(app).history_index()), base as i64); + assert!( + cx.read(|app| !engine.read(app).can_undo()), + "nothing to undo at the bottom command" + ); + + // Jumping forward redoes both edits in order. + cx.update(|app| { + engine.update(app, |engine, cx| engine.jump_history((base + 2) as i64, cx)) + }); + assert_eq!(cx.read(|app| engine.read(app).history_index()), (base + 2) as i64); + let entries = cx.read(|app| engine.read(app).history_entries()); + assert!(entries.iter().all(|e| e.done), "the redo restored every row"); + + oakundo::global::clear().unwrap(); + } + /// M12 P2 acceptance: a real project with a sequence + footage clip /// builds a NON-EMPTY node graph with the wires the node editor shows: /// the footage feeds the clip's `tex_in` (a real edge), and every clip diff --git a/src/panels/commands.rs b/src/panels/commands.rs new file mode 100644 index 000000000..8e48320ba --- /dev/null +++ b/src/panels/commands.rs @@ -0,0 +1,342 @@ +// 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 . + +//! Focused-panel command routing: the Rust counterpart of the C++ +//! `PanelWidget` virtual command interface (`app/panel/panel.h`'s ~39 +//! `play_pause` / `set_in` / `delete_selected` / … overrides). Menu clicks +//! and key presses whose registry entry carries [`Route::FocusedPanel`] +//! land here first: the currently focused panel gets the command, and when +//! it does not implement it (the default methods return `false`) the app +//! shell's global handler runs instead — the +//! `PanelManager::currently_focused()` pattern. +//! +//! Panels override only the subset they implement; [`dispatch_to`] maps an +//! [`ActionId`] onto the matching trait method so panel implementations and +//! the action registry stay in one place. + +use gpui::timeline::Frame; +use gpui::{App, Context, Entity}; +use gpui_widgets::viewer::PlaybackClock; + +use crate::actions::ActionId; +use crate::oakui::{AppEngine, Monitor}; + +/// The commands a dock panel can handle when it is focused. Every method +/// defaults to "not handled" (`false`), so a panel overrides only its +/// subset; returning `true` stops the shell's global fallback from running. +pub trait PanelCommandHandler: Sized { + // --- transport (the panel's monitor) ---------------------------------- + fn play_pause(&mut self, _cx: &mut Context) -> bool { + false + } + fn prev_frame(&mut self, _cx: &mut Context) -> bool { + false + } + fn next_frame(&mut self, _cx: &mut Context) -> bool { + false + } + fn go_to_start(&mut self, _cx: &mut Context) -> bool { + false + } + fn go_to_end(&mut self, _cx: &mut Context) -> bool { + false + } + fn play_in_to_out(&mut self, _cx: &mut Context) -> bool { + false + } + fn go_to_prev_cut(&mut self, _cx: &mut Context) -> bool { + false + } + fn go_to_next_cut(&mut self, _cx: &mut Context) -> bool { + false + } + fn go_to_in(&mut self, _cx: &mut Context) -> bool { + false + } + fn go_to_out(&mut self, _cx: &mut Context) -> bool { + false + } + fn shuttle_left(&mut self, _cx: &mut Context) -> bool { + false + } + fn shuttle_stop(&mut self, _cx: &mut Context) -> bool { + false + } + fn shuttle_right(&mut self, _cx: &mut Context) -> bool { + false + } + + // --- in / out points ---------------------------------------------------- + fn set_in(&mut self, _cx: &mut Context) -> bool { + false + } + fn set_out(&mut self, _cx: &mut Context) -> bool { + false + } + fn reset_in(&mut self, _cx: &mut Context) -> bool { + false + } + fn reset_out(&mut self, _cx: &mut Context) -> bool { + false + } + fn clear_in_out(&mut self, _cx: &mut Context) -> bool { + false + } + + // --- selection ---------------------------------------------------------- + fn select_all(&mut self, _cx: &mut Context) -> bool { + false + } + fn deselect_all(&mut self, _cx: &mut Context) -> bool { + false + } + + // --- editing ------------------------------------------------------------- + fn cut_selected(&mut self, _cx: &mut Context) -> bool { + false + } + fn copy_selected(&mut self, _cx: &mut Context) -> bool { + false + } + fn paste(&mut self, _cx: &mut Context) -> bool { + false + } + fn paste_insert(&mut self, _cx: &mut Context) -> bool { + false + } + fn duplicate(&mut self, _cx: &mut Context) -> bool { + false + } + fn rename_selected(&mut self, _cx: &mut Context) -> bool { + false + } + fn delete_selected(&mut self, _cx: &mut Context) -> bool { + false + } + fn ripple_delete(&mut self, _cx: &mut Context) -> bool { + false + } + fn split_at_playhead(&mut self, _cx: &mut Context) -> bool { + false + } + fn speed_duration(&mut self, _cx: &mut Context) -> bool { + false + } + fn toggle_links(&mut self, _cx: &mut Context) -> bool { + false + } + fn toggle_selected_enabled(&mut self, _cx: &mut Context) -> bool { + false + } + fn insert(&mut self, _cx: &mut Context) -> bool { + false + } + fn overwrite(&mut self, _cx: &mut Context) -> bool { + false + } + fn ripple_to_in(&mut self, _cx: &mut Context) -> bool { + false + } + fn ripple_to_out(&mut self, _cx: &mut Context) -> bool { + false + } + fn edit_to_in(&mut self, _cx: &mut Context) -> bool { + false + } + fn edit_to_out(&mut self, _cx: &mut Context) -> bool { + false + } + fn nudge_left(&mut self, _cx: &mut Context) -> bool { + false + } + fn nudge_right(&mut self, _cx: &mut Context) -> bool { + false + } + fn move_in_to_playhead(&mut self, _cx: &mut Context) -> bool { + false + } + fn move_out_to_playhead(&mut self, _cx: &mut Context) -> bool { + false + } + fn delete_in_to_out(&mut self, _cx: &mut Context) -> bool { + false + } + fn ripple_delete_in_to_out(&mut self, _cx: &mut Context) -> bool { + false + } + fn set_marker(&mut self, _cx: &mut Context) -> bool { + false + } + + // --- view ---------------------------------------------------------------- + fn zoom_in(&mut self, _cx: &mut Context) -> bool { + false + } + fn zoom_out(&mut self, _cx: &mut Context) -> bool { + false + } + fn increase_track_height(&mut self, _cx: &mut Context) -> bool { + false + } + fn decrease_track_height(&mut self, _cx: &mut Context) -> bool { + false + } + fn toggle_show_all(&mut self, _cx: &mut Context) -> bool { + false + } +} + +/// Routes `action` to the matching [`PanelCommandHandler`] method; whether +/// the panel handled it. Actions without a panel command (file ops, tools, +/// …) return `false` and fall through to the shell's global handler. +pub fn dispatch_to( + panel: &mut P, + action: ActionId, + cx: &mut Context

, +) -> bool { + match action { + ActionId::PlayPause => panel.play_pause(cx), + ActionId::PrevFrame => panel.prev_frame(cx), + ActionId::NextFrame => panel.next_frame(cx), + ActionId::GoToStart => panel.go_to_start(cx), + ActionId::GoToEnd => panel.go_to_end(cx), + ActionId::PlayInToOut => panel.play_in_to_out(cx), + ActionId::GoToPrevCut => panel.go_to_prev_cut(cx), + ActionId::GoToNextCut => panel.go_to_next_cut(cx), + ActionId::GoToIn => panel.go_to_in(cx), + ActionId::GoToOut => panel.go_to_out(cx), + ActionId::ShuttleLeft => panel.shuttle_left(cx), + ActionId::ShuttleStop => panel.shuttle_stop(cx), + ActionId::ShuttleRight => panel.shuttle_right(cx), + ActionId::SetInPoint => panel.set_in(cx), + ActionId::SetOutPoint => panel.set_out(cx), + ActionId::ResetIn => panel.reset_in(cx), + ActionId::ResetOut => panel.reset_out(cx), + ActionId::ClearInOut => panel.clear_in_out(cx), + ActionId::SelectAll => panel.select_all(cx), + ActionId::DeselectAll => panel.deselect_all(cx), + ActionId::Cut => panel.cut_selected(cx), + ActionId::Copy => panel.copy_selected(cx), + ActionId::Paste => panel.paste(cx), + ActionId::PasteInsert => panel.paste_insert(cx), + ActionId::Duplicate => panel.duplicate(cx), + ActionId::Rename => panel.rename_selected(cx), + ActionId::Delete => panel.delete_selected(cx), + ActionId::RippleDelete => panel.ripple_delete(cx), + ActionId::SplitAtPlayhead => panel.split_at_playhead(cx), + ActionId::SpeedDuration => panel.speed_duration(cx), + ActionId::LinkUnlink => panel.toggle_links(cx), + ActionId::EnableDisable => panel.toggle_selected_enabled(cx), + ActionId::Insert => panel.insert(cx), + ActionId::Overwrite => panel.overwrite(cx), + ActionId::RippleToIn => panel.ripple_to_in(cx), + ActionId::RippleToOut => panel.ripple_to_out(cx), + ActionId::EditToIn => panel.edit_to_in(cx), + ActionId::EditToOut => panel.edit_to_out(cx), + ActionId::NudgeLeft => panel.nudge_left(cx), + ActionId::NudgeRight => panel.nudge_right(cx), + ActionId::MoveInToPlayhead => panel.move_in_to_playhead(cx), + ActionId::MoveOutToPlayhead => panel.move_out_to_playhead(cx), + ActionId::DeleteInOut => panel.delete_in_to_out(cx), + ActionId::RippleDeleteInOut => panel.ripple_delete_in_to_out(cx), + ActionId::Marker => panel.set_marker(cx), + ActionId::ZoomIn => panel.zoom_in(cx), + ActionId::ZoomOut => panel.zoom_out(cx), + ActionId::IncreaseTrackHeight => panel.increase_track_height(cx), + ActionId::DecreaseTrackHeight => panel.decrease_track_height(cx), + ActionId::ToggleShowAll => panel.toggle_show_all(cx), + _ => false, + } +} + +/// The transport commands a viewer panel routes to one of the engine's +/// monitors (the program viewer drives [`Monitor::Program`], the source +/// viewer [`Monitor::Source`]). Shuttle left steps back one frame — true +/// reverse playback is an engine transport gap (the C++ `decspeed` +/// behavior approximated like the old shortcut table did). Cut navigation +/// and loop are not implemented by the viewers and fall through. +pub fn viewer_transport( + engine: &Entity, + clock: &Entity, + monitor: Monitor, + action: ActionId, + cx: &mut App, +) -> bool { + match action { + ActionId::PlayPause => { + let playing = clock.read(cx).is_playing(); + engine.update(cx, |engine, cx| { + if playing { + engine.pause(monitor, cx); + } else { + engine.play(monitor, cx); + } + }); + true + } + ActionId::PrevFrame => { + engine.update(cx, |engine, cx| engine.step(monitor, -1, cx)); + true + } + ActionId::NextFrame => { + engine.update(cx, |engine, cx| engine.step(monitor, 1, cx)); + true + } + ActionId::GoToStart => { + engine.update(cx, |engine, cx| engine.request_frame(monitor, Frame::ZERO, cx)); + true + } + ActionId::GoToEnd => { + let length = engine.read(cx).sequence_length(); + engine.update(cx, |engine, cx| engine.request_frame(monitor, length, cx)); + true + } + ActionId::ShuttleLeft => { + engine.update(cx, |engine, cx| engine.step(monitor, -1, cx)); + true + } + ActionId::ShuttleStop => { + engine.update(cx, |engine, cx| engine.pause(monitor, cx)); + true + } + ActionId::ShuttleRight => { + engine.update(cx, |engine, cx| engine.play(monitor, cx)); + true + } + ActionId::GoToIn => { + if let Some((start, _)) = engine.read(cx).workarea() { + engine.update(cx, |engine, cx| engine.request_frame(monitor, start, cx)); + } + true + } + ActionId::GoToOut => { + if let Some((_, end)) = engine.read(cx).workarea() { + engine.update(cx, |engine, cx| engine.request_frame(monitor, end, cx)); + } + true + } + ActionId::PlayInToOut => { + // Seek to the work area's start, then play (the engine stops at + // the sequence end; out-point stopping is a transport gap). + if let Some((start, _)) = engine.read(cx).workarea() { + engine.update(cx, |engine, cx| engine.request_frame(monitor, start, cx)); + } + engine.update(cx, |engine, cx| engine.play(monitor, cx)); + true + } + _ => false, + } +} diff --git a/src/panels/effect_library.rs b/src/panels/effect_library.rs index 31c245d59..4d3596a8d 100644 --- a/src/panels/effect_library.rs +++ b/src/panels/effect_library.rs @@ -23,12 +23,13 @@ use gpui::colors::DefaultColors; use gpui::dock::{DockPanel, PanelEvent}; use gpui::{ - div, prelude::*, AnyElement, App, ClickEvent, Context, Entity, EventEmitter, Render, - SharedString, Window, + div, prelude::*, AnyElement, App, ClickEvent, Context, Entity, EventEmitter, MouseButton, + Render, SharedString, Window, }; use crate::i18n; use crate::oakui::AppEngine; +use crate::panels::commands::PanelCommandHandler; use crate::panels::ids::EFFECT_LIBRARY; /// The effect library panel. @@ -46,6 +47,10 @@ impl EffectLibraryPanel { } } +/// The effect library implements no focused-panel commands: everything +/// falls through to the shell's global handler. +impl PanelCommandHandler for EffectLibraryPanel {} + impl Render for EffectLibraryPanel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = cx.default_colors().clone(); @@ -95,6 +100,14 @@ impl Render for EffectLibraryPanel { .size_full() .flex() .flex_col() + // Any click inside the panel makes it the focused panel (the + // dock re-emits this as `DockEvent::PanelFocused`, which the + // shell uses to route focused-panel commands). + .on_mouse_down(MouseButton::Left, { + cx.listener(|_this, _event: &gpui::MouseDownEvent, _window, cx| { + cx.emit(PanelEvent::Focused); + }) + }) .child(list) .child( div() diff --git a/src/panels/history.rs b/src/panels/history.rs index 2c9bf4f1a..d51929e8e 100644 --- a/src/panels/history.rs +++ b/src/panels/history.rs @@ -14,42 +14,120 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -//! The history panel (历史记录): a placeholder list of undo entries, sharing -//! the inspector's dock group per the design. +//! The history panel (历史记录): the real undo stack as a list, mirroring +//! the C++ `HistoryWidget` — two columns (number + action), every command +//! on the stack (done first, then the redoable tail), undone rows gray, +//! the row under the stack pointer selected. Clicking a row jumps the +//! stack to it (`row + 1`); a right-click opens a context menu with +//! undo/redo and a jump-to-row action. use gpui::colors::DefaultColors; use gpui::dock::{DockPanel, PanelEvent}; -use gpui::{div, prelude::*, AnyElement, App, Context, EventEmitter, Render, SharedString, Window}; +use gpui::{ + div, prelude::*, px, AnyElement, App, Context, ElementId, Entity, EventEmitter, MouseButton, + MouseDownEvent, Render, SharedString, Window, +}; +use gpui_widgets::menu::{ContextMenu, ContextMenuEvent, Menu, MenuItem}; +use crate::oakui::AppEngine; +use crate::panels::commands::PanelCommandHandler; use crate::panels::ids::HISTORY; -/// The undo-history placeholder panel. -pub struct HistoryPanel { - /// Demo entries `(i18n key, label suffix, timestamp)`, newest first, - /// matching the design's date format `YYYY-MM-DD HH:mm`. The label is - /// `tr(key) + suffix`, so the verb is localized while clip names stay as - /// data. - entries: Vec<(&'static str, &'static str, &'static str)>, +/// Context-menu item ids. +const MENU_UNDO: usize = 1; +const MENU_REDO: usize = 2; +const MENU_JUMP_HERE: usize = 3; + +/// The history panel over the engine's undo stack. +pub struct HistoryPanel { + engine: Entity, + /// The right-click menu (hidden until a row is right-clicked). + menu: Entity, + /// The row under the last right-click (the "jump here" target). + menu_row: Option, } -impl HistoryPanel { - /// Creates the panel with demo history entries. - pub fn new(_window: &mut Window, _cx: &mut Context) -> Self { +impl HistoryPanel { + /// Builds the panel over `engine`'s undo stack. + pub fn new(engine: Entity, window: &mut Window, cx: &mut Context) -> Self { + // The stack changes whenever the engine notifies (every edit, + // undo, redo and jump runs through the engine), so re-reading on + // observe keeps the rows, the graying and the selection live — + // the C++ model reset itself on Core::undo_index_changed. + cx.observe(&engine, |_this, _engine, cx| cx.notify()).detach(); + + let menu = cx.new(|cx| ContextMenu::new(0, window, cx)); + cx.subscribe(&menu, |this, _menu, event: &ContextMenuEvent, cx| { + this.on_menu(event.item, cx); + }) + .detach(); + Self { - entries: vec![ - ("history.transform", "", "2026-06-03 20:25"), - ("history.move_clip", "", "2026-06-03 20:24"), - ("history.delete_clip", " B-roll.mp4", "2026-06-03 20:22"), - ("history.add_lut", "", "2026-06-03 20:20"), - ("history.set_in_point", "", "2026-06-03 20:18"), - ], + engine, + menu, + menu_row: None, } } + + /// Routes a context-menu action. + fn on_menu(&mut self, item: usize, cx: &mut Context) { + match item { + MENU_UNDO => self.engine.update(cx, |engine, cx| engine.undo(cx)), + MENU_REDO => self.engine.update(cx, |engine, cx| engine.redo(cx)), + MENU_JUMP_HERE => { + if let Some(row) = self.menu_row { + self.engine + .update(cx, |engine, cx| engine.jump_history(row as i64 + 1, cx)); + } + } + _ => {} + } + } + + /// Shows the right-click menu at `position` (window coordinates). + fn show_menu( + &mut self, + row: Option, + position: gpui::Point, + cx: &mut Context, + ) { + let can_undo = self.engine.read(cx).can_undo(); + let can_redo = self.engine.read(cx).can_redo(); + let mut items = vec![ + MenuItem::new(MENU_UNDO, crate::i18n::tr("menu.edit.undo")).with_shortcut("⌘Z"), + MenuItem::new(MENU_REDO, crate::i18n::tr("menu.edit.redo")).with_shortcut("⇧⌘Z"), + ]; + if !can_undo { + items[0] = items[0].clone().disabled(); + } + if !can_redo { + items[1] = items[1].clone().disabled(); + } + if let Some(row) = row { + self.menu_row = Some(row); + items.push(MenuItem::new(MENU_JUMP_HERE, crate::i18n::tr("history.jump_here")).separated()); + } else { + self.menu_row = None; + } + self.menu.update(cx, |menu, cx| { + menu.show(position, Menu::new(items), cx); + }); + } } -impl Render for HistoryPanel { +/// The history panel implements no focused-panel commands: everything falls +/// through to the shell's global handler. +impl PanelCommandHandler for HistoryPanel {} + +impl Render for HistoryPanel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = cx.default_colors().clone(); + let entries = self.engine.read(cx).history_entries(); + let index = self.engine.read(cx).history_index(); + // The C++ widget selects row `index - 1` (the newest done + // command); the bottom empty command keeps the index >= 1. + let selected = (index - 1).max(0) as usize; + let mut list = div() .id("history-list") .flex_1() @@ -57,16 +135,52 @@ impl Render for HistoryPanel { .flex_col() .py_1() .overflow_y_scroll(); - for (key, suffix, timestamp) in &self.entries { - let label = format!("{}{}", crate::i18n::tr(key), suffix); + + for (row, entry) in entries.iter().enumerate() { + let undone = !entry.done; + let is_selected = row == selected; + // The C++ model falls back to tr("Command") for rows without + // a label (the bottom "New/Open Project" command). + let label: SharedString = if entry.name.is_empty() { + crate::i18n::tr("history.command").into() + } else { + entry.name.clone().into() + }; + let row_color = if undone { colors.disabled } else { colors.text }; + list = list.child( div() + .id(ElementId::named_usize("history-row", row)) .flex() .items_center() .gap_2() .px_3() .py_1() - .text_color(colors.text) + .cursor_pointer() + .when(is_selected, |el| el.bg(colors.selected)) + .on_click(cx.listener(move |this, _event: &gpui::ClickEvent, _window, cx| { + // A left click jumps to this row (the C++ + // currentRowChanged → oakengine_undo_jump(row+1)). + this.engine + .update(cx, |engine, cx| engine.jump_history(row as i64 + 1, cx)); + cx.stop_propagation(); + })) + .on_mouse_down( + MouseButton::Right, + cx.listener(move |this, event: &MouseDownEvent, _window, cx| { + this.show_menu(Some(row), event.position, cx); + cx.stop_propagation(); + }), + ) + .child( + // The number column (C++ column 0: row + 1). + div() + .w(px(28.0)) + .flex_shrink_0() + .text_right() + .text_color(colors.disabled) + .child(format!("{}", row + 1)), + ) .child( div() .flex_1() @@ -74,24 +188,56 @@ impl Render for HistoryPanel { .overflow_hidden() .whitespace_nowrap() .text_ellipsis() + .text_color(if is_selected { colors.selected_text } else { row_color }) .child(label), - ) - .child( - div() - .flex_shrink_0() - .whitespace_nowrap() - .text_color(colors.disabled) - .child(*timestamp), ), ); } - div().size_full().flex().flex_col().child(list) + + // An empty stack (no project open) gets a quiet placeholder so the + // panel does not read as broken. + let body: AnyElement = if entries.is_empty() { + div() + .size_full() + .flex() + .items_center() + .justify_center() + .text_color(colors.disabled) + .child(crate::i18n::tr("history.empty")) + .into_any_element() + } else { + list.into_any_element() + }; + + div() + .size_full() + .flex() + .flex_col() + // Any left click inside the panel makes it the focused panel + // (the dock re-emits this as `DockEvent::PanelFocused`, which + // the shell uses to route focused-panel commands). + .on_mouse_down(MouseButton::Left, { + cx.listener(|_this, _event: &MouseDownEvent, _window, cx| { + cx.emit(PanelEvent::Focused); + }) + }) + // Right-clicking the blank area below the rows still opens + // the menu (without the row-specific "jump here" action). + .on_mouse_down( + MouseButton::Right, + cx.listener(|this, event: &MouseDownEvent, _window, cx| { + this.show_menu(None, event.position, cx); + cx.stop_propagation(); + }), + ) + .child(body) + .child(self.menu.clone()) } } -impl EventEmitter for HistoryPanel {} +impl EventEmitter for HistoryPanel {} -impl DockPanel for HistoryPanel { +impl DockPanel for HistoryPanel { fn panel_id(&self) -> gpui::dock::PanelId { HISTORY } @@ -106,3 +252,69 @@ impl DockPanel for HistoryPanel { .into_any_element() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::oakui::MockEngine; + use gpui::{size, Modifiers, TestAppContext, VisualTestContext}; + + /// Builds the panel in a window and returns a `VisualTestContext`. + fn panel_window( + cx: &mut TestAppContext, + ) -> ( + &'static mut VisualTestContext, + Entity>, + ) { + cx.update(|cx| cx.init_colors()); + let window = cx.open_window(size(px(320.0), px(400.0)), |window, cx| { + let engine = cx.new(|cx| MockEngine::demo(cx)); + HistoryPanel::new(engine, window, cx) + }); + cx.run_until_parked(); + let panel = window.root(cx).expect("history panel root"); + let cx = VisualTestContext::from_window(window.into(), cx).into_mut(); + (cx, panel) + } + + /// The mock engine keeps no undo stack, so the panel renders the empty + /// placeholder rather than stale demo rows. + #[gpui::test] + async fn empty_stack_shows_the_placeholder(cx: &mut TestAppContext) { + let (cx, _panel) = panel_window(cx); + cx.update(|window, cx| { + window.draw(cx).clear(); + }); + // No rows rendered; the placeholder branch owns the body. + assert!( + cx.debug_bounds("history-row-0").is_none(), + "no history rows without an undo stack" + ); + } + + /// Right-clicking the blank panel body opens the context menu with the + /// undo/redo entries (both disabled on an empty stack). + #[gpui::test] + async fn right_click_opens_the_context_menu(cx: &mut TestAppContext) { + let (cx, _panel) = panel_window(cx); + cx.update(|window, cx| { + window.draw(cx).clear(); + }); + assert!(cx.debug_bounds("menu-popup").is_none(), "menu starts hidden"); + + cx.simulate_mouse_down( + gpui::point(px(160.0), px(200.0)), + MouseButton::Right, + Modifiers::none(), + ); + cx.run_until_parked(); + cx.update(|window, cx| { + window.draw(cx).clear(); + }); + + let popup = cx + .debug_bounds("menu-popup") + .expect("context menu opened on right-click"); + assert!(popup.size.height > px(20.0), "popup lists the items"); + } +} diff --git a/src/panels/inspector.rs b/src/panels/inspector.rs index 41fbab460..a8986866c 100644 --- a/src/panels/inspector.rs +++ b/src/panels/inspector.rs @@ -21,12 +21,16 @@ use gpui::colors::DefaultColors; use gpui::dock::{DockPanel, PanelEvent}; -use gpui::effect_stack::{EffectStackEvent, EffectStackView}; +use gpui::effect_stack::{EffectCardKind, EffectId, EffectStackEvent, EffectStackView}; use gpui::{ - div, prelude::*, AnyElement, App, Context, Entity, EventEmitter, Render, SharedString, Window, + div, prelude::*, AnyElement, App, Context, Entity, EventEmitter, MouseButton, Render, + SharedString, Window, }; +use gpui_widgets::menu::{Menu, MenuItem}; +use crate::menus::context::{ContextMenuHandle, ContextMenuTriggered}; use crate::oakui::AppEngine; +use crate::panels::commands::PanelCommandHandler; use crate::panels::ids::INSPECTOR; /// The inspector / effect stack panel. @@ -38,11 +42,16 @@ pub struct InspectorPanel { /// engine has a stack target), the panel renders a small menu of the /// engine's addable effects instead of forwarding the bare request. pending_add: Option, + /// The right-click context menu. + context_menu: ContextMenuHandle, + /// The effect card the open context menu targets (id, enabled state, + /// removable flag), when one is on the stack. + context_effect: Option<(EffectId, bool, bool)>, } impl InspectorPanel { /// Builds the stack over `engine`'s effect model. - pub fn new(engine: Entity, _window: &mut Window, cx: &mut Context) -> Self { + pub fn new(engine: Entity, window: &mut Window, cx: &mut Context) -> Self { let stack = cx.new(|cx| { EffectStackView::new(engine.clone(), cx) .params_renderer(|_effect, _window, cx| cx.new(|_cx| ParamPlaceholder).into()) @@ -61,10 +70,46 @@ impl InspectorPanel { }) .detach(); + let context_menu = ContextMenuHandle::new(Self::on_local_menu_item, window, cx); + Self { stack, engine, pending_add: None, + context_menu, + context_effect: None, + } + } + + /// Handles the inspector's local (non-registry) context-menu items: + /// they drive the same [`EffectStackEvent`]s the card widgets emit. + fn on_local_menu_item(&mut self, item: usize, cx: &mut Context) { + let Some((effect, enabled, _)) = self.context_effect else { + return; + }; + match item { + LOCAL_ENABLE => { + self.engine.update(cx, |engine, cx| { + engine.apply_effect_event( + &EffectStackEvent::EnableToggled { + effect, + enabled: !enabled, + }, + cx, + ) + }); + } + LOCAL_REMOVE => { + self.engine.update(cx, |engine, cx| { + engine.apply_effect_event(&EffectStackEvent::RemoveRequested(effect), cx) + }); + } + LOCAL_RENAME | LOCAL_PROPERTIES => { + println!("[inspector] context-menu item {item} (not implemented yet)"); + } + _ => { + println!("[inspector] unhandled local menu item {item}"); + } } } @@ -141,10 +186,48 @@ impl InspectorPanel { } } +/// The inspector implements no focused-panel commands: everything falls +/// through to the shell's global handler. +impl PanelCommandHandler for InspectorPanel {} + impl Render for InspectorPanel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = cx.default_colors().clone(); - let mut root = div().id("inspector-panel").size_full().flex().flex_col(); + let mut root = div() + .id("inspector-panel") + .size_full() + .flex() + .flex_col() + // Any click inside the panel makes it the focused panel (the + // dock re-emits this as `DockEvent::PanelFocused`, which the + // shell uses to route focused-panel commands). + .on_mouse_down(MouseButton::Left, { + cx.listener(|_this, _event: &gpui::MouseDownEvent, _window, cx| { + cx.emit(PanelEvent::Focused); + }) + }) + // The stack widget has no right-click handling of its own; the + // panel opens a menu targeting the first effect card (the stack + // has no per-card pointer context yet). + .on_mouse_down(MouseButton::Right, { + cx.listener(|this, event: &gpui::MouseDownEvent, _window, cx| { + let target = this + .engine + .read(cx) + .effects() + .into_iter() + .find(|effect| effect.kind() == EffectCardKind::Effect) + .map(|effect| (effect.id(), effect.is_enabled(), effect.is_removable())); + this.context_effect = target; + if let Some((_, enabled, removable)) = target { + this.context_menu.show( + event.position, + stack_card_menu(enabled, removable), + cx, + ); + } + }) + }); root = root.child(self.stack.clone()); // The add-effect menu sits below the stack while an add is // pending. It only makes sense while the engine has a stack @@ -156,12 +239,15 @@ impl Render for InspectorPanel { self.pending_add = None; } } - root + // The right-click popup renders anchored above the panel. + root.child(self.context_menu.widget()) } } impl EventEmitter for InspectorPanel {} +impl EventEmitter for InspectorPanel {} + impl DockPanel for InspectorPanel { fn panel_id(&self) -> gpui::dock::PanelId { INSPECTOR @@ -193,3 +279,69 @@ impl Render for ParamPlaceholder { .child(crate::i18n::tr("inspector.params")) } } + +// --------------------------------------------------------------------------- +// Context menu — an effect card's right-click menu: enable/disable and +// remove drive the same `EffectStackEvent`s the card widgets emit; rename +// and properties are placeholders until the dialogs land. +// --------------------------------------------------------------------------- + +/// Local (non-registry) item ids of the inspector's context menu. +const LOCAL_ENABLE: usize = 2501; +const LOCAL_REMOVE: usize = 2502; +const LOCAL_RENAME: usize = 2503; +const LOCAL_PROPERTIES: usize = 2504; + +/// The effect-card context menu. `enabled` picks the Enable/Disable label; +/// `removable` gates the Remove entry. +pub(crate) fn stack_card_menu(enabled: bool, removable: bool) -> Menu { + use crate::i18n::tr; + let enable_label = if enabled { + tr("inspector.context.disable") + } else { + tr("inspector.context.enable") + }; + let mut remove = MenuItem::new(LOCAL_REMOVE, tr("inspector.context.remove")); + if !removable { + remove = remove.disabled(); + } + Menu::new(vec![ + MenuItem::new(LOCAL_ENABLE, enable_label), + remove.separated(), + MenuItem::new(LOCAL_RENAME, tr("inspector.context.rename")), + MenuItem::new(LOCAL_PROPERTIES, tr("menu.context.properties")), + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The enable entry flips its label with the card state, and `removable` + /// gates only the remove entry. + #[test] + fn stack_card_menu_flips_label_and_gates_remove() { + for enabled in [true, false] { + for removable in [true, false] { + let menu = stack_card_menu(enabled, removable); + assert_eq!(menu.items.len(), 4); + + let enable = &menu.items[0]; + assert_eq!(enable.id, LOCAL_ENABLE); + let expected = if enabled { + crate::i18n::tr("inspector.context.disable") + } else { + crate::i18n::tr("inspector.context.enable") + }; + assert_eq!(enable.label, expected); + + let remove = &menu.items[1]; + assert_eq!(remove.id, LOCAL_REMOVE); + assert_eq!(remove.enabled, removable); + + assert_eq!(menu.items[2].id, LOCAL_RENAME); + assert_eq!(menu.items[3].id, LOCAL_PROPERTIES); + } + } + } +} diff --git a/src/panels/mod.rs b/src/panels/mod.rs index dc2ae89ad..59479076c 100644 --- a/src/panels/mod.rs +++ b/src/panels/mod.rs @@ -23,6 +23,7 @@ //! to "the engine", and never mutate engine state directly — every edit is a //! widget request event that the panel forwards through the gateway. +pub mod commands; pub mod effect_library; pub mod history; pub mod inspector; diff --git a/src/panels/node_editor.rs b/src/panels/node_editor.rs index ab993c441..ba6774431 100644 --- a/src/panels/node_editor.rs +++ b/src/panels/node_editor.rs @@ -31,11 +31,15 @@ use gpui::node_graph::{ NodeData, NodeElement, NodeGraphEvent, NodeGraphView, NodeVisualState, MAX_ZOOM, MIN_ZOOM, }; use gpui::{ - div, point, prelude::*, px, AnyElement, App, Bounds, ClickEvent, Context, Entity, EventEmitter, - Pixels, Render, SharedString, Window, + div, point, prelude::*, px, AnyElement, App, Bounds, ClickEvent, Context, Entity, + EventEmitter, MouseButton, Pixels, Point, Render, SharedString, Window, }; +use gpui_widgets::menu::{Menu, MenuItem}; -use crate::oakui::AppEngine; +use crate::menus::context::{ContextMenuHandle, ContextMenuTriggered}; +use crate::menus::shared; +use crate::oakui::{AppEngine, NodeLibraryEntry}; +use crate::panels::commands::PanelCommandHandler; use crate::panels::ids::NODE_EDITOR; /// The node editor panel. @@ -46,6 +50,19 @@ pub struct NodeEditorPanel { /// Whether the initial fit-to-window has been applied (the canvas size is /// only known after the first layout). fitted: bool, + /// The right-click context menu. + context_menu: ContextMenuHandle, + /// Window position of the last right-click: `BackgroundClicked` only + /// carries a graph-space position, so the background menu is placed at + /// the recorded pointer position (the right-click bubbles up to the + /// panel). + last_right_click: Option>, + /// The graph-space position of the last background click — the spot a + /// node added through the Add menu lands on. + add_node_position: Option>, + /// The Add-menu item ids currently on offer, mapped to their factory + /// type ids (rebuilt whenever the menu opens). + add_menu_ids: Vec<(usize, String)>, } impl NodeEditorPanel { @@ -60,14 +77,66 @@ impl NodeEditorPanel { .update(cx, |engine, cx| engine.apply_node_graph_event(event, cx)); }) .detach(); + // The panel-side half of the graph events: the context menus. + cx.subscribe( + &graph, + |this, _graph, event: &NodeGraphEvent, cx| match event { + NodeGraphEvent::BackgroundClicked { position } => { + this.add_node_position = Some(*position); + if let Some(window_position) = this.last_right_click { + let menu = background_menu( + this.engine.read(cx).node_library(), + &mut this.add_menu_ids, + ); + this.context_menu.show(window_position, menu, cx); + } + } + NodeGraphEvent::NodeContextMenuRequested { position, .. } => { + this.context_menu.show(*position, node_menu(), cx); + } + _ => {} + }, + ) + .detach(); + + let context_menu = ContextMenuHandle::new(Self::on_local_menu_item, window, cx); Self { graph, engine, fitted: false, + context_menu, + last_right_click: None, + add_node_position: None, + add_menu_ids: Vec::new(), } } + /// Handles the node editor's local (non-registry) context-menu items. + fn on_local_menu_item(&mut self, item: usize, cx: &mut Context) { + if let Some(color) = shared::color_label_index(item) { + println!("[node editor] set node color label to {color}"); + return; + } + if item >= LOCAL_ADD_NODE_BASE { + let type_id = self + .add_menu_ids + .iter() + .find(|entry| entry.0 == item) + .map(|entry| entry.1.clone()); + if let Some(type_id) = type_id { + let position = self.add_node_position.unwrap_or_default(); + if let Err(err) = self.engine.update(cx, |engine, cx| { + engine.add_node_at(&type_id, position, cx) + }) { + println!("[node editor] add node failed: {err}"); + } + } + return; + } + println!("[node editor] context-menu item {item} (not implemented yet)"); + } + /// The union of every node's bounds in graph space, if the graph is /// non-empty. fn graph_bounds(&self, cx: &App) -> Option> { @@ -130,6 +199,10 @@ impl NodeEditorPanel { } } +/// The node editor implements no focused-panel commands: everything falls +/// through to the shell's global handler. +impl PanelCommandHandler for NodeEditorPanel {} + impl Render for NodeEditorPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { // Fit the graph once the canvas size is known (first layout). Before @@ -148,6 +221,22 @@ impl Render for NodeEditorPanel { .size_full() .flex() .flex_col() + // Any click inside the panel makes it the focused panel (the + // dock re-emits this as `DockEvent::PanelFocused`, which the + // shell uses to route focused-panel commands). + .on_mouse_down(MouseButton::Left, { + cx.listener(|_this, _event: &gpui::MouseDownEvent, _window, cx| { + cx.emit(PanelEvent::Focused); + }) + }) + // The graph's `BackgroundClicked` only carries a graph-space + // position; record the pointer here (right-clicks bubble up) so + // the background menu can open at the window position. + .on_mouse_down(MouseButton::Right, { + cx.listener(|this, event: &gpui::MouseDownEvent, _window, _cx| { + this.last_right_click = Some(event.position); + }) + }) .child( div() .flex() @@ -200,6 +289,8 @@ impl Render for NodeEditorPanel { .min_h_0() .child(self.graph.clone()), ) + // The right-click popup renders anchored above the panel. + .child(self.context_menu.widget()) } } @@ -245,6 +336,8 @@ fn zoom_button( impl EventEmitter for NodeEditorPanel {} +impl EventEmitter for NodeEditorPanel {} + impl DockPanel for NodeEditorPanel { fn panel_id(&self) -> gpui::dock::PanelId { NODE_EDITOR @@ -261,6 +354,101 @@ impl DockPanel for NodeEditorPanel { } } +// --------------------------------------------------------------------------- +// Context menus — the Rust counterpart of the C++ `NodeView:: +// show_context_menu` (`app/widget/nodeview/nodeview.cpp`). +// --------------------------------------------------------------------------- + +/// Local (non-registry) item ids of the node editor's context menus. +const LOCAL_SMOOTH_EDGES: usize = 2401; +const LOCAL_DIR_TOP_BOTTOM: usize = 2402; +const LOCAL_DIR_BOTTOM_TOP: usize = 2403; +const LOCAL_DIR_LEFT_RIGHT: usize = 2404; +const LOCAL_DIR_RIGHT_LEFT: usize = 2405; +const LOCAL_GROUP: usize = 2406; +const LOCAL_UNGROUP: usize = 2407; +const LOCAL_OPEN_IN_VIEWER: usize = 2408; +const LOCAL_SHOW_IN_PARAM_EDITOR: usize = 2409; +const LOCAL_NODE_PROPERTIES: usize = 2410; +/// The Add-menu items occupy `LOCAL_ADD_NODE_BASE..` (one id per library +/// entry; the panel maps them back to factory type ids). +const LOCAL_ADD_NODE_BASE: usize = 2420; + +/// The node context menu: the shared edit section, grouping, color labels, +/// viewer/parameter-editor reveals and properties (the C++ node branch). +pub(crate) fn node_menu() -> Menu { + use crate::i18n::tr; + let mut items = shared::edit_section(false); + if let Some(last) = items.last_mut() { + last.separator_after = true; + } + items.push(MenuItem::new(LOCAL_GROUP, tr("node.context.group"))); + items.push(MenuItem::new(LOCAL_UNGROUP, tr("node.context.ungroup"))); + items.push(shared::color_label_item(None).separated()); + items.push(MenuItem::new(LOCAL_OPEN_IN_VIEWER, tr("node.context.open_in_viewer"))); + items.push(MenuItem::new( + LOCAL_SHOW_IN_PARAM_EDITOR, + tr("node.context.show_in_param_editor"), + )); + items.push(MenuItem::new(LOCAL_NODE_PROPERTIES, tr("menu.context.properties"))); + Menu::new(items) +} + +/// The background context menu: edge smoothing, flow direction and the Add +/// submenu built from the engine's node library (grouped by category, +/// alphabetical inside each group — the C++ `create_add_menu` order). +/// `add_menu_ids` is rewritten to map the fresh item ids to type ids. +pub(crate) fn background_menu( + library: Vec, + add_menu_ids: &mut Vec<(usize, String)>, +) -> Menu { + use crate::i18n::tr; + add_menu_ids.clear(); + + // Group the library by category key (BTreeMap = alphabetical category + // order), then sort each group's entries by name. + let mut groups: std::collections::BTreeMap<&'static str, Vec> = + std::collections::BTreeMap::new(); + for entry in library { + groups.entry(entry.category_key).or_default().push(entry); + } + let mut add_items: Vec = Vec::new(); + let mut next_id = LOCAL_ADD_NODE_BASE; + for (category_key, mut entries) in groups { + entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + let mut submenu = Vec::with_capacity(entries.len()); + for entry in entries { + submenu.push(MenuItem::new(next_id, entry.name.clone())); + add_menu_ids.push((next_id, entry.type_id)); + next_id += 1; + } + add_items.push( + MenuItem::new(0, tr(category_key)).with_submenu(Menu::new(submenu)), + ); + } + + let direction_menu = Menu::new(vec![ + MenuItem::new(LOCAL_DIR_TOP_BOTTOM, tr("node.context.dir_top_bottom")) + .with_checked(true), + MenuItem::new(LOCAL_DIR_BOTTOM_TOP, tr("node.context.dir_bottom_top")) + .with_checked(false), + MenuItem::new(LOCAL_DIR_LEFT_RIGHT, tr("node.context.dir_left_right")) + .with_checked(false), + MenuItem::new(LOCAL_DIR_RIGHT_LEFT, tr("node.context.dir_right_left")) + .with_checked(false), + ]); + + Menu::new(vec![ + MenuItem::new(LOCAL_SMOOTH_EDGES, tr("node.context.smooth_edges")) + .with_checked(false) + .separated(), + MenuItem::new(0, tr("node.context.direction")) + .with_submenu(direction_menu) + .separated(), + MenuItem::new(0, tr("node.context.add")).with_submenu(Menu::new(add_items)), + ]) +} + #[cfg(test)] mod tests { use super::*; @@ -316,4 +504,93 @@ mod tests { ); assert!(state.zoom() > 0.0); } + + /// The node menu wraps the plain edit section with grouping, color + /// labels, the reveal entries and properties. + #[test] + fn node_menu_carries_grouping_and_reveals() { + let menu = node_menu(); + let ids: Vec = menu.items.iter().map(|item| item.id).collect(); + assert!(ids.contains(&LOCAL_GROUP)); + assert!(ids.contains(&LOCAL_UNGROUP)); + assert!(ids.contains(&LOCAL_OPEN_IN_VIEWER)); + assert!(ids.contains(&LOCAL_SHOW_IN_PARAM_EDITOR)); + assert!(ids.contains(&LOCAL_NODE_PROPERTIES)); + + let color = menu + .items + .iter() + .find(|item| item.label == crate::i18n::tr("menu.color.label")) + .expect("color label item"); + assert!(color.separator_after); + } + + fn entry(type_id: &str, name: &str, category_key: &'static str) -> NodeLibraryEntry { + NodeLibraryEntry { + type_id: type_id.to_string(), + name: name.to_string(), + category_key, + } + } + + /// The background menu groups the library alphabetically by category, + /// sorts entries inside each group, and records the id → type-id map + /// starting at `LOCAL_ADD_NODE_BASE`. + #[test] + fn background_menu_groups_the_library() { + let library = vec![ + entry("video.solid", "Solid", "node.category.generator"), + entry("math.add", "Add", "node.category.math"), + entry("video.bars", "Color Bars", "node.category.generator"), + entry("math.multiply", "Multiply", "node.category.math"), + ]; + let mut add_menu_ids = Vec::new(); + let menu = background_menu(library, &mut add_menu_ids); + + // Top level: smooth edges, direction, add. + assert_eq!(menu.items.len(), 3); + assert_eq!(menu.items[0].id, LOCAL_SMOOTH_EDGES); + let add = &menu.items[2]; + assert_eq!(add.label, crate::i18n::tr("node.context.add")); + + // Categories sort alphabetically: generator before math, entries + // sorted case-insensitively inside each group. + let categories = add.submenu.as_ref().unwrap(); + let labels: Vec<_> = categories + .items + .iter() + .map(|item| item.label.clone()) + .collect(); + assert_eq!( + labels, + vec![ + crate::i18n::tr("node.category.generator"), + crate::i18n::tr("node.category.math"), + ] + ); + let generator = &categories.items[0].submenu.as_ref().unwrap().items; + let names: Vec<_> = generator.iter().map(|item| item.label.clone()).collect(); + assert_eq!(names, vec!["Color Bars", "Solid"]); + + // The id map starts at the base and matches submenu order. + assert_eq!(add_menu_ids[0].0, LOCAL_ADD_NODE_BASE); + let mapped: std::collections::HashMap = + add_menu_ids.iter().cloned().collect(); + assert_eq!( + mapped.get(&generator[0].id), + Some(&"video.bars".to_string()) + ); + assert_eq!(mapped.len(), 4); + } + + /// An empty library still yields the smoothing/direction entries, with + /// an empty Add submenu. + #[test] + fn background_menu_survives_an_empty_library() { + let mut add_menu_ids = Vec::new(); + let menu = background_menu(Vec::new(), &mut add_menu_ids); + assert_eq!(menu.items.len(), 3); + assert!(add_menu_ids.is_empty()); + assert!(menu.items[2].submenu.as_ref().unwrap().items.is_empty()); + } } diff --git a/src/panels/program_viewer.rs b/src/panels/program_viewer.rs index 4593ac382..fa81e6dc6 100644 --- a/src/panels/program_viewer.rs +++ b/src/panels/program_viewer.rs @@ -23,15 +23,18 @@ use gpui::colors::DefaultColors; use gpui::dock::{DockPanel, PanelEvent}; use gpui::{ - div, prelude::*, px, AnyElement, App, ClickEvent, Context, Entity, EventEmitter, Render, - SharedString, Window, + div, prelude::*, px, AnyElement, App, ClickEvent, Context, Entity, EventEmitter, MouseButton, + Render, SharedString, Window, }; use gpui_widgets::audio_meter::AudioLevelMeter; use gpui_widgets::scopes::{ChromaDataSource, Histogram, LumaDataSource, Vectorscope, Waveform}; use gpui_widgets::viewer::{ViewerEvent, ViewerWidget}; +use crate::actions::ActionId; +use crate::menus::context::{ContextMenuHandle, ContextMenuTriggered}; use crate::oakui::timecode::{format_fps, format_resolution}; use crate::oakui::{AppEngine, Monitor}; +use crate::panels::commands::{self as panel_commands, PanelCommandHandler}; use crate::panels::ids::PROGRAM_VIEWER; use crate::panels::{chip, viewer_title}; @@ -74,6 +77,9 @@ pub struct ProgramViewerPanel { viewer: Entity>, meter: Entity>, engine: Entity, + /// The program monitor's clock (also owned by the viewer widget; kept + /// here so transport commands can read the playing state). + clock: Entity, /// The last CPU frame handed to the viewer (compared by `Arc` identity so /// a paused playhead does not re-upload the picture every frame). last_cpu_frame: Option>, @@ -87,6 +93,8 @@ pub struct ProgramViewerPanel { waveform: Entity>, /// The vectorscope. vectorscope: Entity>, + /// The right-click context menu. + context_menu: ContextMenuHandle, } impl ProgramViewerPanel { @@ -99,7 +107,7 @@ impl ProgramViewerPanel { window: &mut Window, cx: &mut Context, ) -> Self { - let viewer = cx.new(|cx| ViewerWidget::new(3, clock, window, cx)); + let viewer = cx.new(|cx| ViewerWidget::new(3, clock.clone(), window, cx)); // Route every transport request to the engine's program monitor. cx.subscribe(&viewer, |this, _viewer, event: &ViewerEvent, cx| { let monitor = Monitor::Program; @@ -112,6 +120,9 @@ impl ProgramViewerPanel { }) .detach(); + let context_menu = + ContextMenuHandle::new(Self::on_local_menu_item, window, cx); + let scope_state = cx.new(|_cx| ScopeState { luma: Vec::new(), chroma: Vec::new(), @@ -124,15 +135,31 @@ impl ProgramViewerPanel { viewer, meter, engine, + clock, last_cpu_frame: None, tab: ProgramViewTab::Picture, scope_state, histogram, waveform, vectorscope, + context_menu, } } + /// Handles the viewer's local (non-registry) context-menu items — all + /// placeholders until the viewer widget grows the matching controls. + fn on_local_menu_item(&mut self, item: usize, _cx: &mut Context) { + println!("[program viewer] context-menu item {item} (not implemented yet)"); + } + + /// Routes a transport command to the engine's program monitor through + /// the shared viewer transport helper. + fn transport(&mut self, action: ActionId, cx: &mut Context) -> bool { + let engine = self.engine.clone(); + let clock = self.clock.clone(); + panel_commands::viewer_transport(&engine, &clock, Monitor::Program, action, cx) + } + /// Pushes the engine's current frame into the viewer and the scopes, but /// only when it actually changed (the engine caches one image per /// playhead frame, with the scope samples analyzed in the same pass). @@ -270,6 +297,22 @@ impl Render for ProgramViewerPanel { .flex() .flex_col() .overflow_hidden() + // Any click inside the panel makes it the focused panel (the + // dock re-emits this as `DockEvent::PanelFocused`, which the + // shell uses to route focused-panel commands). + .on_mouse_down(MouseButton::Left, { + cx.listener(|_this, _event: &gpui::MouseDownEvent, _window, cx| { + cx.emit(PanelEvent::Focused); + }) + }) + // The viewer widget has no right-click handling of its own, so + // the panel opens the shared viewer menu here. + .on_mouse_down(MouseButton::Right, { + cx.listener(|this, event: &gpui::MouseDownEvent, _window, cx| { + this.context_menu + .show(event.position, crate::menus::shared::viewer_menu(), cx); + }) + }) .child( div() .flex() @@ -301,11 +344,51 @@ impl Render for ProgramViewerPanel { )), ) .child(body) + // The right-click popup renders anchored above the panel. + .child(self.context_menu.widget()) + } +} + +impl PanelCommandHandler for ProgramViewerPanel { + fn play_pause(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::PlayPause, cx) + } + fn prev_frame(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::PrevFrame, cx) + } + fn next_frame(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::NextFrame, cx) + } + fn go_to_start(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::GoToStart, cx) + } + fn go_to_end(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::GoToEnd, cx) + } + fn play_in_to_out(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::PlayInToOut, cx) + } + fn go_to_in(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::GoToIn, cx) + } + fn go_to_out(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::GoToOut, cx) + } + fn shuttle_left(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::ShuttleLeft, cx) + } + fn shuttle_stop(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::ShuttleStop, cx) + } + fn shuttle_right(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::ShuttleRight, cx) } } impl EventEmitter for ProgramViewerPanel {} +impl EventEmitter for ProgramViewerPanel {} + impl DockPanel for ProgramViewerPanel { fn panel_id(&self) -> gpui::dock::PanelId { PROGRAM_VIEWER diff --git a/src/panels/project_explorer.rs b/src/panels/project_explorer.rs index 065003cb8..f8703fa0a 100644 --- a/src/panels/project_explorer.rs +++ b/src/panels/project_explorer.rs @@ -17,27 +17,40 @@ //! The material bin panel (项目): the `ProjectExplorer` widget over the //! engine's project data. +use std::process::Command; + use gpui::colors::DefaultColors; use gpui::dock::{DockPanel, PanelEvent}; use gpui::{ - div, px, prelude::*, AnyElement, App, Context, Entity, EventEmitter, Render, SharedString, - Window, + div, px, prelude::*, AnyElement, App, Context, Entity, EventEmitter, MouseButton, + PathPromptOptions, Pixels, Point, Render, SharedString, Window, }; +use gpui_widgets::menu::{Menu, MenuItem}; use gpui_widgets::project_explorer::{ProjectExplorer, ProjectExplorerEvent}; +use crate::actions::ActionId; +use crate::menus::context::{ContextMenuHandle, ContextMenuTriggered}; +use crate::menus::shared; use crate::oakui::AppEngine; +use crate::panels::commands::PanelCommandHandler; use crate::panels::ids::PROJECT; /// The material bin panel. pub struct ProjectExplorerPanel { explorer: Entity>, engine: Entity, + /// The right-click context menu. + context_menu: ContextMenuHandle, + /// The entry under the currently open context menu (`None` = the menu + /// was opened on the empty area). + context_entry: Option, } impl ProjectExplorerPanel { /// Builds the explorer over `engine`'s project data. pub fn new(engine: Entity, window: &mut Window, cx: &mut Context) -> Self { let explorer = cx.new(|cx| ProjectExplorer::new(1, engine.clone(), window, cx)); + let context_menu = ContextMenuHandle::new(Self::on_local_menu_item, window, cx); cx.subscribe( &explorer, |this, _explorer, event: &ProjectExplorerEvent, cx| match event { @@ -63,15 +76,97 @@ impl ProjectExplorerPanel { println!("[project explorer] import failed: {err}"); } } + ProjectExplorerEvent::ContextMenuRequested { id, position, .. } => { + this.open_context_menu(*id, *position, cx); + } other => println!("[project explorer] request: {other:?}"), }, ) .detach(); - Self { explorer, engine } + Self { + explorer, + engine, + context_menu, + context_entry: None, + } + } + + /// Opens the context menu for `id` (`None` = the empty area) at + /// `position`. + fn open_context_menu( + &mut self, + id: Option, + position: Point, + cx: &mut Context, + ) { + self.context_entry = id; + let menu = match id { + None => blank_menu(), + Some(id) => { + if self.engine.read(cx).entry_path(id).is_some() { + footage_menu(true) + } else { + entry_menu() + } + } + }; + self.context_menu.show(position, menu, cx); + } + + /// Handles the panel's local (non-registry) context-menu items. + fn on_local_menu_item(&mut self, item: usize, cx: &mut Context) { + match item { + LOCAL_REVEAL_IN_FINDER => { + let path = self + .context_entry + .and_then(|id| self.engine.read(cx).entry_path(id)); + if let Some(path) = path { + reveal_in_finder(&path); + } + } + LOCAL_REPLACE_FOOTAGE => { + let Some(id) = self.context_entry else { + return; + }; + let receiver = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: Some(crate::i18n::tr("project.context.replace_footage").into()), + }); + cx.spawn(async move |this, cx| { + if let Ok(Ok(Some(paths))) = receiver.await { + if let Some(path) = paths.into_iter().next() { + let _ = this.update(cx, |this, cx| { + if let Err(err) = this.engine.update(cx, |engine, cx| { + engine.replace_footage(id, path.clone(), cx) + }) { + println!("[project explorer] replace failed: {err}"); + } + }); + } + } + }) + .detach(); + } + LOCAL_RENAME | LOCAL_DELETE | LOCAL_PROPERTIES | LOCAL_OPEN_IN_NEW_TAB => { + println!("[project explorer] menu action {item} (not implemented yet)"); + } + LOCAL_PROXY_GENERATE | LOCAL_PROXY_USE | LOCAL_PROXY_REVEAL | LOCAL_PROXY_DELETE => { + println!("[project explorer] proxy action {item} (not implemented yet)"); + } + _ => { + println!("[project explorer] unhandled local menu item {item}"); + } + } } } +/// The project bin implements no focused-panel commands: everything falls +/// through to the shell's global handler. +impl PanelCommandHandler for ProjectExplorerPanel {} + impl Render for ProjectExplorerPanel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = cx.default_colors().clone(); @@ -79,6 +174,14 @@ impl Render for ProjectExplorerPanel { .size_full() .flex() .flex_col() + // Any click inside the panel makes it the focused panel (the + // dock re-emits this as `DockEvent::PanelFocused`, which the + // shell uses to route focused-panel commands). + .on_mouse_down(MouseButton::Left, { + cx.listener(|_this, _event: &gpui::MouseDownEvent, _window, cx| { + cx.emit(PanelEvent::Focused); + }) + }) // The panel title row, per the design's panel headers: the // widget below only shows the bare tree/icon view toggles, so // without this row the panel reads as anonymous. @@ -102,11 +205,15 @@ impl Render for ProjectExplorerPanel { .min_h_0() .child(self.explorer.clone()), ) + // The right-click popup renders anchored above the panel. + .child(self.context_menu.widget()) } } impl EventEmitter for ProjectExplorerPanel {} +impl EventEmitter for ProjectExplorerPanel {} + impl DockPanel for ProjectExplorerPanel { fn panel_id(&self) -> gpui::dock::PanelId { PROJECT @@ -122,3 +229,165 @@ impl DockPanel for ProjectExplorerPanel { .into_any_element() } } + +// --------------------------------------------------------------------------- +// Context menus — the Rust counterpart of the C++ +// `ProjectExplorer::show_context_menu` (`app/widget/projectexplorer/`). +// --------------------------------------------------------------------------- + +/// Local (non-registry) item ids of the project explorer's context menus. +const LOCAL_OPEN_IN_NEW_TAB: usize = 2201; +const LOCAL_OPEN_IN_NEW_WINDOW: usize = 2202; +const LOCAL_REVEAL_IN_FINDER: usize = 2203; +const LOCAL_REPLACE_FOOTAGE: usize = 2204; +const LOCAL_PROXY_GENERATE: usize = 2205; +const LOCAL_PROXY_USE: usize = 2206; +const LOCAL_PROXY_REVEAL: usize = 2207; +const LOCAL_PROXY_DELETE: usize = 2208; +const LOCAL_RENAME: usize = 2209; +const LOCAL_DELETE: usize = 2210; +const LOCAL_PROPERTIES: usize = 2211; + +/// The proxy submenu (shared shape with the timeline's; the entries stay +/// disabled until the proxy pipeline lands, the settings entry is the real +/// registry action). +fn proxy_submenu() -> Menu { + Menu::new(vec![ + MenuItem::new(LOCAL_PROXY_GENERATE, crate::i18n::tr("timeline.context.generate_proxy")) + .disabled(), + MenuItem::new(LOCAL_PROXY_USE, crate::i18n::tr("timeline.context.use_proxy")).disabled(), + MenuItem::new(LOCAL_PROXY_REVEAL, crate::i18n::tr("timeline.context.reveal_proxy")) + .disabled(), + MenuItem::new(LOCAL_PROXY_DELETE, crate::i18n::tr("timeline.context.delete_proxy")) + .disabled(), + shared::action_item(ActionId::ProxySettings).separated(), + ]) +} + +/// The empty-area context menu: New + Import. +pub(crate) fn blank_menu() -> Menu { + Menu::new(vec![ + MenuItem::new(0, crate::i18n::tr("project.context.new")) + .with_submenu(Menu::new(shared::new_section())), + shared::action_item(ActionId::Import), + ]) +} + +/// A footage entry's context menu: reveal + replace, the proxy submenu, +/// then rename / delete / properties. +pub(crate) fn footage_menu(reveal_enabled: bool) -> Menu { + let mut reveal = + MenuItem::new(LOCAL_REVEAL_IN_FINDER, crate::i18n::tr("project.context.reveal_in_finder")); + if !reveal_enabled { + reveal = reveal.disabled(); + } + Menu::new(vec![ + reveal, + MenuItem::new(LOCAL_REPLACE_FOOTAGE, crate::i18n::tr("project.context.replace_footage")) + .separated(), + MenuItem::new(0, crate::i18n::tr("timeline.context.proxy")).with_submenu(proxy_submenu()), + MenuItem::new(LOCAL_RENAME, crate::i18n::tr("project.context.rename")).separated(), + MenuItem::new(LOCAL_DELETE, crate::i18n::tr("project.context.delete")), + MenuItem::new(LOCAL_PROPERTIES, crate::i18n::tr("menu.context.properties")).separated(), + ]) +} + +/// A non-footage entry's context menu (folder / sequence): open-in-new-tab, +/// then rename / delete / properties. +pub(crate) fn entry_menu() -> Menu { + Menu::new(vec![ + MenuItem::new(LOCAL_OPEN_IN_NEW_TAB, crate::i18n::tr("project.context.open_in_new_tab")), + MenuItem::new( + LOCAL_OPEN_IN_NEW_WINDOW, + crate::i18n::tr("project.context.open_in_new_window"), + ) + .separated(), + MenuItem::new(LOCAL_RENAME, crate::i18n::tr("project.context.rename")).separated(), + MenuItem::new(LOCAL_DELETE, crate::i18n::tr("project.context.delete")), + MenuItem::new(LOCAL_PROPERTIES, crate::i18n::tr("menu.context.properties")).separated(), + ]) +} + +/// Reveals `path` in the platform file manager (Finder on macOS, Explorer +/// on Windows, `xdg-open` on the parent directory elsewhere). +fn reveal_in_finder(path: &std::path::Path) { + let result = if cfg!(target_os = "macos") { + Command::new("open").arg("-R").arg(path).spawn() + } else if cfg!(target_os = "windows") { + Command::new("explorer").arg(format!("/select,{}", path.display())).spawn() + } else { + let dir = path.parent().unwrap_or(path); + Command::new("xdg-open").arg(dir).spawn() + }; + if let Err(err) = result { + println!("[project explorer] reveal failed: {err}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The blank-area menu is New ▸ (the shared new section) + Import. + #[test] + fn blank_menu_is_new_and_import() { + let menu = blank_menu(); + assert_eq!(menu.items.len(), 2); + let new = &menu.items[0]; + assert_eq!(new.label, crate::i18n::tr("project.context.new")); + assert_eq!(new.submenu.as_ref().unwrap().items.len(), 3); + assert_eq!( + menu.items[1].id, + ActionId::Import.entry().menu_id() + ); + } + + /// The footage menu gates only the reveal entry on `reveal_enabled`; + /// every other entry keeps its state. + #[test] + fn footage_menu_gates_the_reveal_entry() { + for reveal_enabled in [true, false] { + let menu = footage_menu(reveal_enabled); + let reveal = menu + .items + .iter() + .find(|item| item.id == LOCAL_REVEAL_IN_FINDER) + .expect("reveal entry"); + assert_eq!(reveal.enabled, reveal_enabled); + + let ids: Vec = menu.items.iter().map(|item| item.id).collect(); + assert_eq!( + ids, + vec![ + LOCAL_REVEAL_IN_FINDER, + LOCAL_REPLACE_FOOTAGE, + 0, // proxy submenu header + LOCAL_RENAME, + LOCAL_DELETE, + LOCAL_PROPERTIES, + ] + ); + let proxy = &menu.items[2].submenu.as_ref().unwrap().items; + assert_eq!(proxy.len(), 5); + assert!(proxy[..4].iter().all(|item| !item.enabled)); + assert!(proxy[4].enabled); + } + } + + /// The non-footage entry menu offers the open-in-new-tab/window pair + /// before rename/delete/properties. + #[test] + fn entry_menu_offers_open_in_new_tab_first() { + let ids: Vec = entry_menu().items.iter().map(|item| item.id).collect(); + assert_eq!( + ids, + vec![ + LOCAL_OPEN_IN_NEW_TAB, + LOCAL_OPEN_IN_NEW_WINDOW, + LOCAL_RENAME, + LOCAL_DELETE, + LOCAL_PROPERTIES, + ] + ); + } +} diff --git a/src/panels/source_viewer.rs b/src/panels/source_viewer.rs index b330513f3..386377e4c 100644 --- a/src/panels/source_viewer.rs +++ b/src/panels/source_viewer.rs @@ -20,12 +20,16 @@ use gpui::colors::DefaultColors; use gpui::dock::{DockPanel, PanelEvent}; use gpui::{ - div, prelude::*, AnyElement, App, Context, Entity, EventEmitter, Render, SharedString, Window, + div, prelude::*, AnyElement, App, Context, Entity, EventEmitter, MouseButton, Render, + SharedString, Window, }; use gpui_widgets::viewer::{ViewerEvent, ViewerWidget}; +use crate::actions::ActionId; +use crate::menus::context::{ContextMenuHandle, ContextMenuTriggered}; use crate::oakui::timecode::{format_fps, format_resolution}; use crate::oakui::{AppEngine, Monitor}; +use crate::panels::commands::{self as panel_commands, PanelCommandHandler}; use crate::panels::{chip, viewer_title}; use crate::panels::ids::SOURCE_VIEWER; @@ -33,9 +37,14 @@ use crate::panels::ids::SOURCE_VIEWER; pub struct SourceViewerPanel { viewer: Entity>, engine: Entity, + /// The source monitor's clock (also owned by the viewer widget; kept + /// here so transport commands can read the playing state). + clock: Entity, /// The last CPU frame handed to the viewer (compared by `Arc` identity so /// a paused playhead does not re-upload the picture every frame). last_cpu_frame: Option>, + /// The right-click context menu. + context_menu: ContextMenuHandle, } impl SourceViewerPanel { @@ -46,7 +55,7 @@ impl SourceViewerPanel { window: &mut Window, cx: &mut Context, ) -> Self { - let viewer = cx.new(|cx| ViewerWidget::new(2, clock, window, cx)); + let viewer = cx.new(|cx| ViewerWidget::new(2, clock.clone(), window, cx)); // Route every transport request to the engine's source monitor. cx.subscribe(&viewer, |this, _viewer, event: &ViewerEvent, cx| { let monitor = Monitor::Source; @@ -59,13 +68,32 @@ impl SourceViewerPanel { }) .detach(); + let context_menu = + ContextMenuHandle::new(Self::on_local_menu_item, window, cx); + Self { viewer, engine, + clock, last_cpu_frame: None, + context_menu, } } + /// Handles the viewer's local (non-registry) context-menu items — all + /// placeholders until the viewer widget grows the matching controls. + fn on_local_menu_item(&mut self, item: usize, _cx: &mut Context) { + println!("[source viewer] context-menu item {item} (not implemented yet)"); + } + + /// Routes a transport command to the engine's source monitor through + /// the shared viewer transport helper. + fn transport(&mut self, action: ActionId, cx: &mut Context) -> bool { + let engine = self.engine.clone(); + let clock = self.clock.clone(); + panel_commands::viewer_transport(&engine, &clock, Monitor::Source, action, cx) + } + /// Pushes the engine's synthetic test frame into the viewer, but only when /// it actually changed (the engine caches one image per playhead frame). fn sync_frame(&mut self, cx: &mut Context) { @@ -102,6 +130,22 @@ impl Render for SourceViewerPanel { .flex() .flex_col() .overflow_hidden() + // Any click inside the panel makes it the focused panel (the + // dock re-emits this as `DockEvent::PanelFocused`, which the + // shell uses to route focused-panel commands). + .on_mouse_down(MouseButton::Left, { + cx.listener(|_this, _event: &gpui::MouseDownEvent, _window, cx| { + cx.emit(PanelEvent::Focused); + }) + }) + // The viewer widget has no right-click handling of its own, so + // the panel opens the shared viewer menu here. + .on_mouse_down(MouseButton::Right, { + cx.listener(|this, event: &gpui::MouseDownEvent, _window, cx| { + this.context_menu + .show(event.position, crate::menus::shared::viewer_menu(), cx); + }) + }) .child( div() .flex() @@ -126,11 +170,51 @@ impl Render for SourceViewerPanel { .overflow_hidden() .child(self.viewer.clone()), ) + // The right-click popup renders anchored above the panel. + .child(self.context_menu.widget()) + } +} + +impl PanelCommandHandler for SourceViewerPanel { + fn play_pause(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::PlayPause, cx) + } + fn prev_frame(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::PrevFrame, cx) + } + fn next_frame(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::NextFrame, cx) + } + fn go_to_start(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::GoToStart, cx) + } + fn go_to_end(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::GoToEnd, cx) + } + fn play_in_to_out(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::PlayInToOut, cx) + } + fn go_to_in(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::GoToIn, cx) + } + fn go_to_out(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::GoToOut, cx) + } + fn shuttle_left(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::ShuttleLeft, cx) + } + fn shuttle_stop(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::ShuttleStop, cx) + } + fn shuttle_right(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::ShuttleRight, cx) } } impl EventEmitter for SourceViewerPanel {} +impl EventEmitter for SourceViewerPanel {} + impl DockPanel for SourceViewerPanel { fn panel_id(&self) -> gpui::dock::PanelId { SOURCE_VIEWER diff --git a/src/panels/timeline.rs b/src/panels/timeline.rs index 2398d90ca..98c217d64 100644 --- a/src/panels/timeline.rs +++ b/src/panels/timeline.rs @@ -42,19 +42,28 @@ use gpui::colors::DefaultColors; use gpui::dock::{DockPanel, PanelEvent}; use gpui::timeline::{ - Frame, TimelineView, TrackData, TrackKind, HEADER_WIDTH, MIN_TRACK_HEIGHT, RULER_HEIGHT, + ClipData, ClipId, Frame, TimelineEvent, TimelineHit, TimelineView, TrackData, TrackKind, + HEADER_WIDTH, MIN_TRACK_HEIGHT, RULER_HEIGHT, +}; +use gpui::{ + div, img, prelude::*, px, Context, Entity, MouseButton, Pixels, Point, Window, }; -use gpui::{div, img, prelude::*, px, Context, Entity, Window}; use gpui::{AnyElement, App, ClickEvent, DragMoveEvent, EventEmitter, Render, SharedString}; use gpui_widgets::checkbox::{CheckBox, CheckBoxEvent, CheckState}; +use gpui_widgets::menu::{Menu, MenuItem}; +use gpui_widgets::viewer::PlaybackClock; use gpui_widgets::project_explorer::FootageDrag; use gpui_widgets::slider::{Slider, SliderEvent, SliderModel}; use gpui_widgets::tooltip::tooltip_view; use gpui_widgets::value::ValueKind; +use crate::actions::ActionId; use crate::i18n; +use crate::menus::context::{ContextMenuHandle, ContextMenuTriggered}; +use crate::menus::shared; use crate::oakui::icons; -use crate::oakui::AppEngine; +use crate::oakui::{AppEngine, Monitor}; +use crate::panels::commands::{self as panel_commands, PanelCommandHandler}; use crate::panels::ids::TIMELINE; /// Toolbar height, per the design (31px). @@ -92,6 +101,12 @@ pub struct TimelinePanel { /// the cursor plus the start frame. `None` outside the clip area or while /// no footage drag is active. footage_drop: Option, + /// The right-click context menu (opened from + /// [`TimelineEvent::ContextMenuRequested`]). + context_menu: ContextMenuHandle, + /// The track behind the currently open track-head menu (the "Delete" + /// item's target); `None` when a different menu is open. + context_track: Option, } /// A footage drop target resolved from the cursor: the display track under @@ -165,6 +180,21 @@ impl TimelinePanel { }) .detach(); + // The right-click menu: the view reports what was hit + // (`ContextMenuRequested`), the panel assembles the matching menu + // and opens the popup at the click position. + let context_menu = + ContextMenuHandle::new(Self::on_local_menu_item, window, cx); + cx.subscribe( + &timeline, + |this, _view, event: &TimelineEvent, cx| { + if let TimelineEvent::ContextMenuRequested { position, hit } = event { + this.open_context_menu(*position, hit.clone(), cx); + } + }, + ) + .detach(); + Self { timeline, engine, @@ -173,6 +203,68 @@ impl TimelinePanel { snap, selected_tool: 0, footage_drop: None, + context_menu, + context_track: None, + } + } + + /// Opens the context menu matching `hit` at `position` (window + /// coordinates). Registry-backed items leave through + /// [`ContextMenuTriggered`]; local items are handled by + /// [`Self::on_local_menu_item`]. + fn open_context_menu( + &mut self, + position: Point, + hit: TimelineHit, + cx: &mut Context, + ) { + let menu = match &hit { + TimelineHit::Clip(_) => clip_menu(), + TimelineHit::Empty { .. } => empty_area_menu(), + TimelineHit::TrackHead(track) => { + self.context_track = Some(*track); + track_head_menu() + } + TimelineHit::RulerMarker(_) => marker_menu(), + TimelineHit::Ruler(_) => ruler_menu(), + }; + if !matches!(hit, TimelineHit::TrackHead(_)) { + self.context_track = None; + } + self.context_menu.show(position, menu, cx); + } + + /// Handles the timeline's local (non-registry) context-menu items. + fn on_local_menu_item(&mut self, item: usize, cx: &mut Context) { + // Color labels apply to the selected clips; the engine has no + // clip-color surface yet, so they log for now (kept visible so the + // wiring is testable in the demo). + if let Some(color) = shared::color_label_index(item) { + println!("[timeline] set clip color label to {color}"); + return; + } + match item { + LOCAL_DELETE_TRACK => { + if let Some(track) = self.context_track { + self.engine.update(cx, |engine, cx| engine.remove_track(track, cx)); + } + } + LOCAL_DELETE_ALL_EMPTY => { + self.engine.update(cx, |engine, cx| engine.delete_empty_tracks(cx)); + } + LOCAL_CACHE_ALL | LOCAL_CACHE_IN_OUT | LOCAL_CACHE_DISCARD => { + println!("[timeline] cache action {item} (not implemented yet)"); + } + LOCAL_TIMECODE_DROP_FRAME + | LOCAL_TIMECODE_NON_DROP_FRAME + | LOCAL_TIMECODE_SECONDS + | LOCAL_TIMECODE_FRAMES + | LOCAL_TIMECODE_MILLISECONDS => { + println!("[timeline] timecode display {item} (not implemented yet)"); + } + _ => { + println!("[timeline] unhandled local menu item {item}"); + } } } @@ -241,6 +333,240 @@ impl TimelinePanel { engine.drop_footage(drag.0, track_kind, track_index, time, cx); }); } + + /// Routes a transport command to the engine's program monitor (the + /// timeline shuttles the program, like the viewers do when focused). + fn transport(&mut self, action: ActionId, cx: &mut Context) -> bool { + let engine = self.engine.clone(); + let clock = self.engine.read(cx).program_clock().clone(); + panel_commands::viewer_transport(&engine, &clock, Monitor::Program, action, cx) + } + + /// Deletes the selected clips (ripple or gap) through the engine's edit + /// commands (the focused-panel counterpart of the shell's Edit menu). + fn delete_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)); + } + } + + /// Moves the work area's start (`in_point`) or end to the program + /// playhead as ONE undoable entry — the same commit the shell's + /// playback-menu in/out points use. + fn set_point_at_playhead(&mut self, in_point: bool, cx: &mut Context) { + let clock = self.engine.read(cx).program_clock().clone(); + let playhead = 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!("[timeline] set in/out point: empty range, ignored"); + return; + } + self.engine.update(cx, |engine, cx| { + engine.commit_workarea(old_start, old_end, start, end, cx); + }); + } + + /// Scales the timeline zoom around its left edge (the focused-panel + /// counterpart of 视图 → 放大/缩小). + 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)); + } +} + +impl PanelCommandHandler for TimelinePanel { + // --- transport (the program monitor) --- + fn play_pause(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::PlayPause, cx) + } + fn prev_frame(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::PrevFrame, cx) + } + fn next_frame(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::NextFrame, cx) + } + fn go_to_start(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::GoToStart, cx) + } + fn go_to_end(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::GoToEnd, cx) + } + fn play_in_to_out(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::PlayInToOut, cx) + } + fn go_to_in(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::GoToIn, cx) + } + fn go_to_out(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::GoToOut, cx) + } + fn shuttle_left(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::ShuttleLeft, cx) + } + fn shuttle_stop(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::ShuttleStop, cx) + } + fn shuttle_right(&mut self, cx: &mut Context) -> bool { + self.transport(ActionId::ShuttleRight, cx) + } + + // --- in / out points (the work area) --- + fn set_in(&mut self, cx: &mut Context) -> bool { + self.set_point_at_playhead(true, cx); + true + } + fn set_out(&mut self, cx: &mut Context) -> bool { + self.set_point_at_playhead(false, cx); + true + } + fn reset_in(&mut self, cx: &mut Context) -> bool { + // Reset the in point to the sequence start, keeping the out point. + let seq_len = self + .engine + .read(cx) + .current_sequence() + .map(|s| s.length) + .unwrap_or(Frame(1)); + let (_old_start, old_end) = self + .engine + .read(cx) + .workarea() + .unwrap_or((Frame::ZERO, seq_len)); + let end = old_end.max(Frame(1)); + self.engine.update(cx, |engine, cx| { + engine.commit_workarea(_old_start, old_end, Frame::ZERO, end, cx); + }); + true + } + fn reset_out(&mut self, cx: &mut Context) -> bool { + // Reset the out point to the sequence end, keeping the in point. + let seq_len = self + .engine + .read(cx) + .current_sequence() + .map(|s| s.length) + .unwrap_or(Frame(1)); + let (old_start, _old_end) = self + .engine + .read(cx) + .workarea() + .unwrap_or((Frame::ZERO, seq_len)); + let end = seq_len.max(Frame(old_start.0 + 1)); + self.engine.update(cx, |engine, cx| { + engine.commit_workarea(old_start, _old_end, old_start, end, cx); + }); + true + } + fn clear_in_out(&mut self, cx: &mut Context) -> bool { + self.engine.update(cx, |engine, cx| engine.clear_workarea(cx)); + true + } + + // --- selection --- + fn select_all(&mut self, cx: &mut Context) -> bool { + 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)); + true + } + fn deselect_all(&mut self, cx: &mut Context) -> bool { + 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)); + true + } + + // --- editing --- + fn delete_selected(&mut self, cx: &mut Context) -> bool { + self.delete_selection(false, cx); + true + } + fn ripple_delete(&mut self, cx: &mut Context) -> bool { + self.delete_selection(true, cx); + true + } + fn split_at_playhead(&mut self, cx: &mut Context) -> bool { + self.engine + .update(cx, |engine, cx| engine.split_at_playhead(cx)); + true + } + fn set_marker(&mut self, cx: &mut Context) -> bool { + self.engine + .update(cx, |engine, cx| engine.add_marker_at_playhead(cx)); + true + } + + // --- view --- + fn zoom_in(&mut self, cx: &mut Context) -> bool { + self.zoom_timeline(1.25, cx); + true + } + fn zoom_out(&mut self, cx: &mut Context) -> bool { + self.zoom_timeline(0.8, cx); + true + } + fn increase_track_height(&mut self, cx: &mut Context) -> bool { + self.nudge_track_height(8.0, cx); + true + } + fn decrease_track_height(&mut self, cx: &mut Context) -> bool { + self.nudge_track_height(-8.0, cx); + true + } } impl Render for TimelinePanel { @@ -402,6 +728,14 @@ impl Render for TimelinePanel { .flex() .flex_col() .overflow_hidden() + // Any click inside the panel makes it the focused panel (the + // dock re-emits this as `DockEvent::PanelFocused`, which the + // shell uses to route focused-panel commands). + .on_mouse_down(MouseButton::Left, { + cx.listener(|_this, _event: &gpui::MouseDownEvent, _window, cx| { + cx.emit(PanelEvent::Focused); + }) + }) .child(toolbar) .child( div() @@ -433,11 +767,15 @@ impl Render for TimelinePanel { ) .child(right_controls), ) + // The right-click popup renders anchored above the panel. + .child(self.context_menu.widget()) } } impl EventEmitter for TimelinePanel {} +impl EventEmitter for TimelinePanel {} + impl DockPanel for TimelinePanel { fn panel_id(&self) -> gpui::dock::PanelId { TIMELINE @@ -452,6 +790,207 @@ impl DockPanel for TimelinePanel { } } +// --------------------------------------------------------------------------- +// Context menus — the Rust counterpart of the C++ +// `TimelineWidget::show_context_menu` (clip + empty area), the +// `TrackViewItem` track-head menu and the `TimeRuler` / +// `SeekableWidget` ruler menus. +// --------------------------------------------------------------------------- + +/// Local (non-registry) item ids of the timeline's context menus. +const LOCAL_USE_AUDIO_TIME_UNITS: usize = 2101; +const LOCAL_SHOW_WAVEFORMS: usize = 2102; +const LOCAL_THUMBNAIL_OFF: usize = 2103; +const LOCAL_THUMBNAIL_IN_OUT: usize = 2104; +const LOCAL_THUMBNAIL_ON: usize = 2105; +const LOCAL_SYNC_SOURCE_TIME: usize = 2106; +const LOCAL_SYNC_WAVEFORM: usize = 2107; +const LOCAL_SYNC_WAVEFORM_SPEED: usize = 2108; +const LOCAL_CACHE_AUTO: usize = 2109; +const LOCAL_CACHE_ALL: usize = 2110; +const LOCAL_CACHE_IN_OUT: usize = 2111; +const LOCAL_CACHE_DISCARD: usize = 2112; +const LOCAL_PROXY_GENERATE: usize = 2113; +const LOCAL_PROXY_USE: usize = 2114; +const LOCAL_PROXY_REVEAL: usize = 2115; +const LOCAL_PROXY_DELETE: usize = 2116; +const LOCAL_REVEAL_FOOTAGE_VIEWER: usize = 2117; +const LOCAL_REVEAL_PROJECT: usize = 2118; +const LOCAL_MULTICAM: usize = 2119; +const LOCAL_DELETE_TRACK: usize = 2120; +const LOCAL_DELETE_ALL_EMPTY: usize = 2121; +const LOCAL_MARKER_PROPERTIES: usize = 2122; +const LOCAL_TIMECODE_DROP_FRAME: usize = 2123; +const LOCAL_TIMECODE_NON_DROP_FRAME: usize = 2124; +const LOCAL_TIMECODE_SECONDS: usize = 2125; +const LOCAL_TIMECODE_FRAMES: usize = 2126; +const LOCAL_TIMECODE_MILLISECONDS: usize = 2127; + +/// A registry-backed item shown under a "Properties" label (the C++ clip +/// and sequence "Properties" entries open the Speed/Duration and Sequence +/// dialogs respectively, so the item keeps the registry id — and with it +/// the shared dispatch path — while wearing the dialog's menu label). +fn properties_item(action: ActionId) -> MenuItem { + let entry = action.entry(); + let mut item = MenuItem::new(entry.menu_id(), i18n::tr("menu.context.properties")); + if let Some(shortcut) = crate::actions::display_shortcut(action) { + item = item.with_shortcut(shortcut); + } + item +} + +/// The clip context menu (`TimelineWidget::show_context_menu` with a +/// selection): the shared clip-edit section, color labels, the synchronize +/// / cache / proxy groups, reveal entries and "Properties". +pub(crate) fn clip_menu() -> Menu { + let mut items = shared::edit_section(true); + // The C++ puts a separator between the edit section and the color + // labels, and another after them. + if let Some(last) = items.last_mut() { + last.separator_after = true; + } + items.push(shared::color_label_item(None).separated()); + // Synchronize group: needs ≥ 2 clips with matching media in the C++; + // the engine has no sync surface yet, so the entries stay disabled. + items.push( + MenuItem::new(LOCAL_SYNC_SOURCE_TIME, i18n::tr("timeline.context.sync_source_time")) + .disabled(), + ); + items.push( + MenuItem::new(LOCAL_SYNC_WAVEFORM, i18n::tr("timeline.context.sync_waveform")).disabled(), + ); + items.push( + MenuItem::new( + LOCAL_SYNC_WAVEFORM_SPEED, + i18n::tr("timeline.context.sync_waveform_speed"), + ) + .disabled() + .separated(), + ); + // Cache group (placeholders: the engine has no cache surface yet). + let cache_menu = Menu::new(vec![ + MenuItem::new(LOCAL_CACHE_AUTO, i18n::tr("timeline.context.auto_cache")) + .with_checked(false) + .separated(), + MenuItem::new(LOCAL_CACHE_ALL, i18n::tr("timeline.context.cache_all")), + MenuItem::new(LOCAL_CACHE_IN_OUT, i18n::tr("timeline.context.cache_in_out")), + MenuItem::new(LOCAL_CACHE_DISCARD, i18n::tr("timeline.context.cache_discard")), + ]); + items.push(MenuItem::new(0, i18n::tr("timeline.context.cache")).with_submenu(cache_menu)); + // Proxy group: disabled until the proxy pipeline lands; the settings + // entry is the real registry action. + let proxy_menu = Menu::new(vec![ + MenuItem::new(LOCAL_PROXY_GENERATE, i18n::tr("timeline.context.generate_proxy")) + .disabled(), + MenuItem::new(LOCAL_PROXY_USE, i18n::tr("timeline.context.use_proxy")).disabled(), + MenuItem::new(LOCAL_PROXY_REVEAL, i18n::tr("timeline.context.reveal_proxy")).disabled(), + MenuItem::new(LOCAL_PROXY_DELETE, i18n::tr("timeline.context.delete_proxy")).disabled(), + shared::action_item(ActionId::ProxySettings).separated(), + ]); + items.push(MenuItem::new(0, i18n::tr("timeline.context.proxy")).with_submenu(proxy_menu)); + // Reveal / multi-cam entries (the C++ shows them only when the clip is + // connected to a viewer; the mock keeps them visible but disabled). + items.push( + MenuItem::new( + LOCAL_REVEAL_FOOTAGE_VIEWER, + i18n::tr("timeline.context.reveal_in_footage_viewer"), + ) + .disabled(), + ); + items.push( + MenuItem::new(LOCAL_REVEAL_PROJECT, i18n::tr("timeline.context.reveal_in_project")) + .disabled(), + ); + items.push( + MenuItem::new(LOCAL_MULTICAM, i18n::tr("timeline.context.multicam")) + .with_checked(false) + .disabled() + .separated(), + ); + items.push(properties_item(ActionId::SpeedDuration)); + Menu::new(items) +} + +/// The empty-area context menu (no clips selected): view toggles plus the +/// sequence "Properties" entry. +pub(crate) fn empty_area_menu() -> Menu { + let thumbnails = Menu::new(vec![ + MenuItem::new(LOCAL_THUMBNAIL_OFF, i18n::tr("timeline.context.thumbnails_off")) + .with_checked(false), + MenuItem::new( + LOCAL_THUMBNAIL_IN_OUT, + i18n::tr("timeline.context.thumbnails_at_in_points"), + ) + .with_checked(false), + MenuItem::new(LOCAL_THUMBNAIL_ON, i18n::tr("timeline.context.thumbnails_on")) + .with_checked(false), + ]); + Menu::new(vec![ + MenuItem::new( + LOCAL_USE_AUDIO_TIME_UNITS, + i18n::tr("timeline.context.use_audio_time_units"), + ) + .with_checked(false), + MenuItem::new(0, i18n::tr("timeline.context.show_thumbnails")) + .with_submenu(thumbnails), + MenuItem::new(LOCAL_SHOW_WAVEFORMS, i18n::tr("timeline.context.show_waveforms")) + .with_checked(false) + .separated(), + properties_item(ActionId::SequenceSettings), + ]) +} + +/// The track-header context menu (`TrackViewItem`): delete this track, or +/// every empty track. +pub(crate) fn track_head_menu() -> Menu { + Menu::new(vec![ + MenuItem::new(LOCAL_DELETE_TRACK, i18n::tr("timeline.context.delete_track")), + MenuItem::new(LOCAL_DELETE_ALL_EMPTY, i18n::tr("timeline.context.delete_all_empty")), + ]) +} + +/// The marker context menu (`SeekableWidget`): color labels, the plain +/// edit section and marker properties. +pub(crate) fn marker_menu() -> Menu { + let mut items = vec![shared::color_label_item(None).separated()]; + let mut edit_items = shared::edit_section(false); + // Separator before the trailing "Properties" entry (the C++ layout). + if let Some(last) = edit_items.last_mut() { + last.separator_after = true; + } + items.extend(edit_items); + items.push(MenuItem::new( + LOCAL_MARKER_PROPERTIES, + i18n::tr("menu.context.properties"), + )); + Menu::new(items) +} + +/// The ruler context menu (`TimeRuler`): the timecode-display radio group. +pub(crate) fn ruler_menu() -> Menu { + Menu::new(vec![ + MenuItem::new( + LOCAL_TIMECODE_DROP_FRAME, + i18n::tr("timeline.context.timecode_drop_frame"), + ) + .with_checked(false), + MenuItem::new( + LOCAL_TIMECODE_NON_DROP_FRAME, + i18n::tr("timeline.context.timecode_non_drop_frame"), + ) + .with_checked(false), + MenuItem::new(LOCAL_TIMECODE_SECONDS, i18n::tr("timeline.context.timecode_seconds")) + .with_checked(false), + MenuItem::new(LOCAL_TIMECODE_FRAMES, i18n::tr("timeline.context.timecode_frames")) + .with_checked(false), + MenuItem::new( + LOCAL_TIMECODE_MILLISECONDS, + i18n::tr("timeline.context.timecode_milliseconds"), + ) + .with_checked(false), + ]) +} + #[cfg(test)] mod tests { use super::*; @@ -562,4 +1101,159 @@ mod tests { assert!((f32::from(after.size.width) - RIGHT_CONTROLS_WIDTH).abs() < 0.5); assert!(canvas.right() <= after.left()); } + + /// Right-clicking inside the timeline body opens the popup: the view + /// emits `ContextMenuRequested`, the panel assembles the matching menu + /// and the `ContextMenu` popup renders. + #[gpui::test] + async fn right_click_opens_the_context_menu(cx: &mut TestAppContext) { + let (cx, _panel) = panel_window(cx, 1600.0, 900.0); + cx.update(|window, cx| { + window.draw(cx).clear(); + }); + assert!(cx.debug_bounds("menu-popup").is_none(), "menu starts hidden"); + + let canvas = cx + .debug_bounds("timeline-canvas") + .expect("timeline canvas rendered"); + let click = gpui::point( + canvas.origin.x + canvas.size.width * 0.5, + canvas.origin.y + canvas.size.height * 0.5, + ); + cx.simulate_mouse_down(click, gpui::MouseButton::Right, gpui::Modifiers::none()); + cx.run_until_parked(); + cx.update(|window, cx| { + window.draw(cx).clear(); + }); + + let popup = cx + .debug_bounds("menu-popup") + .expect("context menu opened on right-click"); + assert!(popup.size.height > px(20.0), "popup lists the items"); + } + + /// The clip menu keeps the C++ shape: edit section, color labels, the + /// three (disabled) synchronize entries, cache and proxy submenus, the + /// reveal/multi-cam entries and a registry-backed "Properties". + #[test] + fn clip_menu_keeps_the_cpp_shape() { + let menu = clip_menu(); + // Color label item sits right after the edit section and carries a + // submenu of all 16 labels. + let color = menu + .items + .iter() + .find(|item| item.submenu.is_some() && item.label == i18n::tr("menu.color.label")) + .expect("color label item"); + assert_eq!( + color.submenu.as_ref().unwrap().items.len(), + shared::COLOR_LABEL_COUNT + ); + + for id in [ + LOCAL_SYNC_SOURCE_TIME, + LOCAL_SYNC_WAVEFORM, + LOCAL_SYNC_WAVEFORM_SPEED, + LOCAL_REVEAL_FOOTAGE_VIEWER, + LOCAL_REVEAL_PROJECT, + LOCAL_MULTICAM, + ] { + let item = menu.items.iter().find(|item| item.id == id).unwrap_or_else(|| { + panic!("clip menu missing disabled placeholder id {id}") + }); + assert!(!item.enabled, "placeholder {id} should be disabled"); + } + + // Cache and proxy are submenus; every proxy entry but the settings + // action is disabled. + let cache = menu + .items + .iter() + .find(|item| item.label == i18n::tr("timeline.context.cache")) + .expect("cache submenu"); + assert_eq!(cache.submenu.as_ref().unwrap().items.len(), 4); + let proxy = menu + .items + .iter() + .find(|item| item.label == i18n::tr("timeline.context.proxy")) + .expect("proxy submenu"); + let proxy_items = &proxy.submenu.as_ref().unwrap().items; + assert_eq!(proxy_items.len(), 5); + assert!(proxy_items[..4].iter().all(|item| !item.enabled)); + assert!(proxy_items[4].enabled); + + // "Properties" dispatches through the speed/duration registry entry. + let properties = menu.items.last().expect("properties is the clip menu tail"); + assert_eq!( + properties.id, + ActionId::SpeedDuration.entry().menu_id() + ); + } + + /// The empty-area menu exposes the view toggles plus the sequence + /// settings "Properties" entry. + #[test] + fn empty_area_menu_toggles_and_properties() { + let menu = empty_area_menu(); + let thumbnails = menu + .items + .iter() + .find(|item| item.label == i18n::tr("timeline.context.show_thumbnails")) + .expect("thumbnails submenu"); + let sub = &thumbnails.submenu.as_ref().unwrap().items; + let ids: Vec = sub.iter().map(|item| item.id).collect(); + assert_eq!( + ids, + vec![LOCAL_THUMBNAIL_OFF, LOCAL_THUMBNAIL_IN_OUT, LOCAL_THUMBNAIL_ON] + ); + assert!(sub.iter().all(|item| item.checked == Some(false))); + + let properties = menu.items.last().expect("properties tail"); + assert_eq!( + properties.id, + ActionId::SequenceSettings.entry().menu_id() + ); + } + + /// The track-header menu is exactly the two delete entries. + #[test] + fn track_head_menu_is_the_two_delete_entries() { + let ids: Vec = track_head_menu() + .items + .iter() + .map(|item| item.id) + .collect(); + assert_eq!(ids, vec![LOCAL_DELETE_TRACK, LOCAL_DELETE_ALL_EMPTY]); + } + + /// The marker menu pairs the color labels with the plain edit section + /// and a local marker-properties entry. + #[test] + fn marker_menu_pairs_color_labels_with_edit_section() { + let menu = marker_menu(); + assert!(menu.items[0].label == i18n::tr("menu.color.label")); + assert!(menu.items[0].separator_after); + let last = menu.items.last().expect("marker properties tail"); + assert_eq!(last.id, LOCAL_MARKER_PROPERTIES); + assert_eq!(last.label, i18n::tr("menu.context.properties")); + } + + /// The ruler menu is the timecode-display radio group, all unchecked by + /// default. + #[test] + fn ruler_menu_is_the_timecode_radio_group() { + let menu = ruler_menu(); + let ids: Vec = menu.items.iter().map(|item| item.id).collect(); + assert_eq!( + ids, + vec![ + LOCAL_TIMECODE_DROP_FRAME, + LOCAL_TIMECODE_NON_DROP_FRAME, + LOCAL_TIMECODE_SECONDS, + LOCAL_TIMECODE_FRAMES, + LOCAL_TIMECODE_MILLISECONDS, + ] + ); + assert!(menu.items.iter().all(|item| item.checked == Some(false))); + } } diff --git a/src/shortcuts.rs b/src/shortcuts.rs deleted file mode 100644 index 6a09f0aab..000000000 --- a/src/shortcuts.rs +++ /dev/null @@ -1,198 +0,0 @@ -// 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 keyboard shortcut table (M12 P5c): a flat keystroke → menu-action -//! map, so a key press and a menu click dispatch the SAME action id through -//! `OakApp::on_menu` (the menu bar's `with_shortcut` labels mirror the -//! `display` strings here). -//! -//! The set follows the C++ Olive layout: space toggles playback, J/K/L form -//! the shuttle (J steps back — true reverse playback is an engine transport -//! gap), I/O set the work-area in/out points at the playhead, S splits, A -//! selects all, Delete / Shift-Delete delete (gap / ripple), and the -//! platform-modifier file/edit shortcuts use `secondary` (⌘ on macOS, Ctrl -//! elsewhere). -//! -//! The table is plain data (no gpui keymap contexts): `action_for` matches -//! a [`gpui::Keystroke`] against it, and the shell's root key listener does -//! the dispatch. While a modal dialog is open the shell skips the table -//! entirely, so the dialogs' text fields never trigger editing actions. - -use gpui::Keystroke; - -use crate::app::menu_ids; - -/// One shortcut entry: the gpui keystroke pattern (the -/// [`Keystroke::parse`] syntax), the menu action it dispatches, and the -/// label the menus show. -pub struct Shortcut { - /// The keystroke pattern, e.g. `"secondary-z"` or `"space"`. - pub keystroke: &'static str, - /// The dispatched menu action id (`crate::app::menu_ids`). - pub action: usize, - /// The menu label for the keystroke (display only). - pub display: &'static str, -} - -/// The shortcut table, in menu order. `secondary` is the platform command -/// modifier (⌘ on macOS, Ctrl on Windows/Linux). -pub const SHORTCUTS: &[Shortcut] = &[ - // --- File --- - Shortcut { keystroke: "secondary-n", action: menu_ids::NEW_PROJECT, display: "⌘N" }, - Shortcut { keystroke: "secondary-o", action: menu_ids::OPEN_PROJECT, display: "⌘O" }, - Shortcut { keystroke: "secondary-s", action: menu_ids::EXPORT_PROJECT, display: "⌘S" }, - Shortcut { keystroke: "secondary-e", action: menu_ids::EXPORT, display: "⌘E" }, - Shortcut { keystroke: "secondary-q", action: menu_ids::QUIT, display: "⌘Q" }, - // --- Edit --- - Shortcut { keystroke: "secondary-z", action: menu_ids::UNDO, display: "⌘Z" }, - Shortcut { keystroke: "secondary-shift-z", action: menu_ids::REDO, display: "⇧⌘Z" }, - Shortcut { keystroke: "secondary-x", action: menu_ids::CUT, display: "⌘X" }, - Shortcut { keystroke: "secondary-c", action: menu_ids::COPY, display: "⌘C" }, - Shortcut { keystroke: "secondary-v", action: menu_ids::PASTE, display: "⌘V" }, - Shortcut { keystroke: "backspace", action: menu_ids::DELETE, display: "⌫" }, - Shortcut { keystroke: "delete", action: menu_ids::DELETE, display: "⌫" }, - Shortcut { keystroke: "shift-backspace", action: menu_ids::RIPPLE_DELETE, display: "⇧⌫" }, - Shortcut { keystroke: "shift-delete", action: menu_ids::RIPPLE_DELETE, display: "⇧⌫" }, - Shortcut { keystroke: "a", action: menu_ids::SELECT_ALL, display: "A" }, - Shortcut { keystroke: "secondary-a", action: menu_ids::SELECT_ALL, display: "⌘A" }, - // --- View --- - // Zoom-in covers both the unshifted "=" key and the shifted "+" (gpui - // reports the base key with the shift modifier on most layouts; the - // bare "+" catches layouts that report the shifted character). - Shortcut { keystroke: "=", action: menu_ids::ZOOM_IN, display: "+" }, - Shortcut { keystroke: "shift-=", action: menu_ids::ZOOM_IN, display: "+" }, - Shortcut { keystroke: "+", action: menu_ids::ZOOM_IN, display: "+" }, - Shortcut { keystroke: "-", action: menu_ids::ZOOM_OUT, display: "-" }, - Shortcut { keystroke: "secondary-,", action: menu_ids::PREFERENCES, display: "⌘," }, - // --- Playback --- - Shortcut { keystroke: "space", action: menu_ids::PLAY_PAUSE, display: "空格" }, - Shortcut { keystroke: "left", action: menu_ids::PREV_FRAME, display: "←" }, - Shortcut { keystroke: "right", action: menu_ids::NEXT_FRAME, display: "→" }, - Shortcut { keystroke: "home", action: menu_ids::TO_START, display: "Home" }, - // The J/K/L shuttle: J steps back (true reverse playback is an engine - // transport gap), K pauses, L plays. - Shortcut { keystroke: "j", action: menu_ids::PREV_FRAME, display: "J" }, - Shortcut { keystroke: "k", action: menu_ids::PAUSE, display: "K" }, - Shortcut { keystroke: "l", action: menu_ids::PLAY, display: "L" }, - // --- Sequence --- - Shortcut { keystroke: "s", action: menu_ids::SPLIT_AT_PLAYHEAD, display: "S" }, - Shortcut { keystroke: "i", action: menu_ids::SET_IN_POINT, display: "I" }, - Shortcut { keystroke: "o", action: menu_ids::SET_OUT_POINT, display: "O" }, - Shortcut { keystroke: "m", action: menu_ids::ADD_MARKER, display: "M" }, -]; - -/// The parsed table (lazily built once; every pattern is a compile-time -/// constant, so a parse failure is a bug the tests below catch). -fn parsed() -> &'static Vec<(Keystroke, usize)> { - static TABLE: std::sync::OnceLock> = std::sync::OnceLock::new(); - TABLE.get_or_init(|| { - SHORTCUTS - .iter() - .map(|s| { - ( - Keystroke::parse(s.keystroke) - .unwrap_or_else(|_| panic!("invalid shortcut keystroke {:?}", s.keystroke)), - s.action, - ) - }) - .collect() - }) -} - -/// The menu action bound to `keystroke`, if any. Matching is exact on the -/// key and the full modifier set (a shortcut with no modifiers does not -/// fire when shift is held, so shifted typing never triggers edits). -pub fn action_for(keystroke: &Keystroke) -> Option { - parsed() - .iter() - .find(|(k, _)| k.key == keystroke.key && k.modifiers == keystroke.modifiers) - .map(|(_, action)| *action) -} - -/// The display label for an action's first shortcut (the menus' source). -pub fn display_for(action: usize) -> Option<&'static str> { - SHORTCUTS - .iter() - .find(|s| s.action == action) - .map(|s| s.display) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Every keystroke in the table parses (the table is static data, so a - /// typo would otherwise only surface as a dead shortcut at runtime). - #[test] - fn every_shortcut_keystroke_parses() { - for shortcut in SHORTCUTS { - assert!( - Keystroke::parse(shortcut.keystroke).is_ok(), - "invalid keystroke {:?}", - shortcut.keystroke - ); - } - } - - /// The main key bindings map to the documented actions (space playback, - /// J/K/L shuttle, I/O points, S split, A select-all, delete flavors, - /// undo/redo, the file shortcuts and the track zoom). - #[test] - fn main_keys_dispatch_their_actions() { - let action = |pattern: &str| action_for(&Keystroke::parse(pattern).unwrap()); - - assert_eq!(action("space"), Some(menu_ids::PLAY_PAUSE)); - assert_eq!(action("j"), Some(menu_ids::PREV_FRAME)); - assert_eq!(action("k"), Some(menu_ids::PAUSE)); - assert_eq!(action("l"), Some(menu_ids::PLAY)); - assert_eq!(action("i"), Some(menu_ids::SET_IN_POINT)); - assert_eq!(action("o"), Some(menu_ids::SET_OUT_POINT)); - assert_eq!(action("s"), Some(menu_ids::SPLIT_AT_PLAYHEAD)); - assert_eq!(action("a"), Some(menu_ids::SELECT_ALL)); - assert_eq!(action("secondary-a"), Some(menu_ids::SELECT_ALL)); - assert_eq!(action("backspace"), Some(menu_ids::DELETE)); - assert_eq!(action("shift-backspace"), Some(menu_ids::RIPPLE_DELETE)); - assert_eq!(action("secondary-z"), Some(menu_ids::UNDO)); - assert_eq!(action("secondary-shift-z"), Some(menu_ids::REDO)); - assert_eq!(action("secondary-n"), Some(menu_ids::NEW_PROJECT)); - assert_eq!(action("secondary-o"), Some(menu_ids::OPEN_PROJECT)); - assert_eq!(action("secondary-s"), Some(menu_ids::EXPORT_PROJECT)); - assert_eq!(action("="), Some(menu_ids::ZOOM_IN)); - assert_eq!(action("-"), Some(menu_ids::ZOOM_OUT)); - assert_eq!(action("m"), Some(menu_ids::ADD_MARKER)); - assert_eq!(action("left"), Some(menu_ids::PREV_FRAME)); - assert_eq!(action("right"), Some(menu_ids::NEXT_FRAME)); - assert_eq!(action("home"), Some(menu_ids::TO_START)); - assert_eq!(action("secondary-,"), Some(menu_ids::PREFERENCES)); - } - - /// A shortcut without modifiers must not fire while shift is held (so - /// shifted keys — e.g. typing capitals — never trigger edits). - #[test] - fn unmodified_shortcuts_ignore_extra_modifiers() { - let shifted = Keystroke::parse("shift-s").unwrap(); - assert_eq!(action_for(&shifted), None); - let cmd = Keystroke::parse("secondary-i").unwrap(); - assert_eq!(action_for(&cmd), None); - } - - /// Unbound keys map to nothing. - #[test] - fn unbound_keys_map_to_nothing() { - assert_eq!(action_for(&Keystroke::parse("f1").unwrap()), None); - assert_eq!(action_for(&Keystroke::parse("x").unwrap()), None); - } -}