feat(app): gpui action/keymap shortcut system, full main menu, context menus
Port the C++ menu/shortcut architecture (origin/main) to the Rust shell: - src/actions.rs: action registry (123 entries with stable C++ ids, i18n keys, default key bindings, routing targets) driving both the menu bar and App::bind_keys; src/shortcuts.rs flat table removed. - src/panels/commands.rs: PanelCommandHandler trait routing playback, editing, zoom, markers etc. to the currently focused panel. - make_menus rebuilt from the registry: full File/Edit/View/Playback/ Sequence/Window/Tools/Help trees aligned with the C++ main menu. - src/menus/: shared context-menu infrastructure; right-click menus for timeline (clip/empty/track head/ruler), project explorer, viewers, node editor, inspector effect stack, with i18n EN/ZH. Synchronize/Proxy/Multi-Cam entries exist but stay disabled pending their engine wiring phases.
This commit is contained in:
+1
-1
Submodule gpui updated: 6f75d3c92a...050d5ba22d
+558
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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<dyn Action>,
|
||||
}
|
||||
|
||||
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<KeyBinding> {
|
||||
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<String> {
|
||||
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<Tool> {
|
||||
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<usize>) {
|
||||
for item in &menu.items {
|
||||
out.push(item.id);
|
||||
if let Some(sub) = &item.submenu {
|
||||
collect(sub, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ids = Vec::new();
|
||||
for entry in 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());
|
||||
}
|
||||
}
|
||||
+761
-246
File diff suppressed because it is too large
Load Diff
+400
-32
@@ -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!(
|
||||
|
||||
+6
-2
@@ -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() {
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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<ContextMenu>,
|
||||
}
|
||||
|
||||
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<P, F>(on_local_item: F, window: &mut Window, cx: &mut Context<P>) -> Self
|
||||
where
|
||||
P: EventEmitter<ContextMenuTriggered>,
|
||||
F: Fn(&mut P, usize, &mut Context<P>) + '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<Pixels>, 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<ContextMenu> {
|
||||
self.menu.clone()
|
||||
}
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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;
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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<MenuItem> {
|
||||
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<MenuItem> {
|
||||
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<MenuItem> {
|
||||
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<MenuItem> {
|
||||
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<usize>) -> 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<usize>) -> 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<usize> {
|
||||
(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<usize> = 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<usize> = 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<_>>(),
|
||||
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<_>>(),
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+175
-2
@@ -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<Self>);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 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<NodeLibraryEntry> {
|
||||
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<Pixels>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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<Self>) {
|
||||
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<PathBuf> {
|
||||
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<Self>,
|
||||
) -> 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<Self>);
|
||||
|
||||
/// 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<HistoryEntry> {
|
||||
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<Self>) {
|
||||
let _ = (index, cx);
|
||||
}
|
||||
|
||||
/// Starts a new blank project with a single default sequence.
|
||||
fn new_project(&mut self, cx: &mut Context<Self>);
|
||||
|
||||
|
||||
+54
-1
@@ -479,6 +479,10 @@ pub struct MockEngine {
|
||||
edges: Vec<MockEdge>,
|
||||
/// 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<NodeId>,
|
||||
@@ -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<Self>) {
|
||||
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<Pixels>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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>) {
|
||||
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
|
||||
|
||||
+2
-2
@@ -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};
|
||||
|
||||
+158
-12
@@ -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>) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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<super::HistoryEntry> {
|
||||
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<Self>) {
|
||||
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<PathBuf> {
|
||||
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<Self>,
|
||||
) -> 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::<oaknode::footage::FootageBehavior>())
|
||||
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
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn prev_frame(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn next_frame(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn go_to_start(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn go_to_end(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn play_in_to_out(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn go_to_prev_cut(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn go_to_next_cut(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn go_to_in(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn go_to_out(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn shuttle_left(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn shuttle_stop(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn shuttle_right(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
// --- in / out points ----------------------------------------------------
|
||||
fn set_in(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn set_out(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn reset_in(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn reset_out(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn clear_in_out(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
// --- selection ----------------------------------------------------------
|
||||
fn select_all(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn deselect_all(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
// --- editing -------------------------------------------------------------
|
||||
fn cut_selected(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn copy_selected(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn paste(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn paste_insert(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn duplicate(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn rename_selected(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn delete_selected(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn ripple_delete(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn split_at_playhead(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn speed_duration(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn toggle_links(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn toggle_selected_enabled(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn insert(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn overwrite(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn ripple_to_in(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn ripple_to_out(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn edit_to_in(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn edit_to_out(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn nudge_left(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn nudge_right(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn move_in_to_playhead(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn move_out_to_playhead(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn delete_in_to_out(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn ripple_delete_in_to_out(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn set_marker(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
// --- view ----------------------------------------------------------------
|
||||
fn zoom_in(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn zoom_out(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn increase_track_height(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn decrease_track_height(&mut self, _cx: &mut Context<Self>) -> bool {
|
||||
false
|
||||
}
|
||||
fn toggle_show_all(&mut self, _cx: &mut Context<Self>) -> 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<P: PanelCommandHandler>(
|
||||
panel: &mut P,
|
||||
action: ActionId,
|
||||
cx: &mut Context<P>,
|
||||
) -> 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<E: AppEngine>(
|
||||
engine: &Entity<E>,
|
||||
clock: &Entity<E::Clock>,
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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<E: AppEngine> EffectLibraryPanel<E> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The effect library implements no focused-panel commands: everything
|
||||
/// falls through to the shell's global handler.
|
||||
impl<E: AppEngine> PanelCommandHandler for EffectLibraryPanel<E> {}
|
||||
|
||||
impl<E: AppEngine> Render for EffectLibraryPanel<E> {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
@@ -95,6 +100,14 @@ impl<E: AppEngine> Render for EffectLibraryPanel<E> {
|
||||
.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()
|
||||
|
||||
+246
-34
@@ -14,42 +14,120 @@
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! The 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<E: AppEngine> {
|
||||
engine: Entity<E>,
|
||||
/// The right-click menu (hidden until a row is right-clicked).
|
||||
menu: Entity<ContextMenu>,
|
||||
/// The row under the last right-click (the "jump here" target).
|
||||
menu_row: Option<usize>,
|
||||
}
|
||||
|
||||
impl HistoryPanel {
|
||||
/// Creates the panel with demo history entries.
|
||||
pub fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
|
||||
impl<E: AppEngine> HistoryPanel<E> {
|
||||
/// Builds the panel over `engine`'s undo stack.
|
||||
pub fn new(engine: Entity<E>, window: &mut Window, cx: &mut Context<Self>) -> 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<Self>) {
|
||||
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<usize>,
|
||||
position: gpui::Point<gpui::Pixels>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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<E: AppEngine> PanelCommandHandler for HistoryPanel<E> {}
|
||||
|
||||
impl<E: AppEngine> Render for HistoryPanel<E> {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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<PanelEvent> for HistoryPanel {}
|
||||
impl<E: AppEngine> EventEmitter<PanelEvent> for HistoryPanel<E> {}
|
||||
|
||||
impl DockPanel for HistoryPanel {
|
||||
impl<E: AppEngine> DockPanel for HistoryPanel<E> {
|
||||
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<HistoryPanel<MockEngine>>,
|
||||
) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
+157
-5
@@ -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<E: AppEngine> {
|
||||
/// 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<usize>,
|
||||
/// 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<E: AppEngine> InspectorPanel<E> {
|
||||
/// Builds the stack over `engine`'s effect model.
|
||||
pub fn new(engine: Entity<E>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
pub fn new(engine: Entity<E>, window: &mut Window, cx: &mut Context<Self>) -> 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<E: AppEngine> InspectorPanel<E> {
|
||||
})
|
||||
.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<Self>) {
|
||||
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<E: AppEngine> InspectorPanel<E> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The inspector implements no focused-panel commands: everything falls
|
||||
/// through to the shell's global handler.
|
||||
impl<E: AppEngine> PanelCommandHandler for InspectorPanel<E> {}
|
||||
|
||||
impl<E: AppEngine> Render for InspectorPanel<E> {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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<E: AppEngine> Render for InspectorPanel<E> {
|
||||
self.pending_add = None;
|
||||
}
|
||||
}
|
||||
root
|
||||
// The right-click popup renders anchored above the panel.
|
||||
root.child(self.context_menu.widget())
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: AppEngine> EventEmitter<PanelEvent> for InspectorPanel<E> {}
|
||||
|
||||
impl<E: AppEngine> EventEmitter<ContextMenuTriggered> for InspectorPanel<E> {}
|
||||
|
||||
impl<E: AppEngine> DockPanel for InspectorPanel<E> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+280
-3
@@ -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<E: AppEngine> {
|
||||
/// 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<Point<Pixels>>,
|
||||
/// The graph-space position of the last background click — the spot a
|
||||
/// node added through the Add menu lands on.
|
||||
add_node_position: Option<Point<Pixels>>,
|
||||
/// 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<E: AppEngine> NodeEditorPanel<E> {
|
||||
@@ -60,14 +77,66 @@ impl<E: AppEngine> NodeEditorPanel<E> {
|
||||
.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<Self>) {
|
||||
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<Bounds<Pixels>> {
|
||||
@@ -130,6 +199,10 @@ impl<E: AppEngine> NodeEditorPanel<E> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The node editor implements no focused-panel commands: everything falls
|
||||
/// through to the shell's global handler.
|
||||
impl<E: AppEngine> PanelCommandHandler for NodeEditorPanel<E> {}
|
||||
|
||||
impl<E: AppEngine> Render for NodeEditorPanel<E> {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
// Fit the graph once the canvas size is known (first layout). Before
|
||||
@@ -148,6 +221,22 @@ impl<E: AppEngine> Render for NodeEditorPanel<E> {
|
||||
.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<E: AppEngine> Render for NodeEditorPanel<E> {
|
||||
.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<E: AppEngine>(
|
||||
|
||||
impl<E: AppEngine> EventEmitter<PanelEvent> for NodeEditorPanel<E> {}
|
||||
|
||||
impl<E: AppEngine> EventEmitter<ContextMenuTriggered> for NodeEditorPanel<E> {}
|
||||
|
||||
impl<E: AppEngine> DockPanel for NodeEditorPanel<E> {
|
||||
fn panel_id(&self) -> gpui::dock::PanelId {
|
||||
NODE_EDITOR
|
||||
@@ -261,6 +354,101 @@ impl<E: AppEngine> DockPanel for NodeEditorPanel<E> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<NodeLibraryEntry>,
|
||||
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<NodeLibraryEntry>> =
|
||||
std::collections::BTreeMap::new();
|
||||
for entry in library {
|
||||
groups.entry(entry.category_key).or_default().push(entry);
|
||||
}
|
||||
let mut add_items: Vec<MenuItem> = 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<usize> = 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<usize, String> =
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<E: AppEngine> {
|
||||
viewer: Entity<ViewerWidget<E::Clock>>,
|
||||
meter: Entity<AudioLevelMeter<E>>,
|
||||
engine: Entity<E>,
|
||||
/// The program monitor's clock (also owned by the viewer widget; kept
|
||||
/// here so transport commands can read the playing state).
|
||||
clock: Entity<E::Clock>,
|
||||
/// 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<std::sync::Arc<gpui::RenderImage>>,
|
||||
@@ -87,6 +93,8 @@ pub struct ProgramViewerPanel<E: AppEngine> {
|
||||
waveform: Entity<Waveform<ScopeState>>,
|
||||
/// The vectorscope.
|
||||
vectorscope: Entity<Vectorscope<ScopeState>>,
|
||||
/// The right-click context menu.
|
||||
context_menu: ContextMenuHandle,
|
||||
}
|
||||
|
||||
impl<E: AppEngine> ProgramViewerPanel<E> {
|
||||
@@ -99,7 +107,7 @@ impl<E: AppEngine> ProgramViewerPanel<E> {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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<E: AppEngine> ProgramViewerPanel<E> {
|
||||
})
|
||||
.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<E: AppEngine> ProgramViewerPanel<E> {
|
||||
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<Self>) {
|
||||
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<Self>) -> 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<E: AppEngine> Render for ProgramViewerPanel<E> {
|
||||
.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<E: AppEngine> Render for ProgramViewerPanel<E> {
|
||||
)),
|
||||
)
|
||||
.child(body)
|
||||
// The right-click popup renders anchored above the panel.
|
||||
.child(self.context_menu.widget())
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: AppEngine> PanelCommandHandler for ProgramViewerPanel<E> {
|
||||
fn play_pause(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::PlayPause, cx)
|
||||
}
|
||||
fn prev_frame(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::PrevFrame, cx)
|
||||
}
|
||||
fn next_frame(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::NextFrame, cx)
|
||||
}
|
||||
fn go_to_start(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::GoToStart, cx)
|
||||
}
|
||||
fn go_to_end(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::GoToEnd, cx)
|
||||
}
|
||||
fn play_in_to_out(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::PlayInToOut, cx)
|
||||
}
|
||||
fn go_to_in(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::GoToIn, cx)
|
||||
}
|
||||
fn go_to_out(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::GoToOut, cx)
|
||||
}
|
||||
fn shuttle_left(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::ShuttleLeft, cx)
|
||||
}
|
||||
fn shuttle_stop(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::ShuttleStop, cx)
|
||||
}
|
||||
fn shuttle_right(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::ShuttleRight, cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: AppEngine> EventEmitter<PanelEvent> for ProgramViewerPanel<E> {}
|
||||
|
||||
impl<E: AppEngine> EventEmitter<ContextMenuTriggered> for ProgramViewerPanel<E> {}
|
||||
|
||||
impl<E: AppEngine> DockPanel for ProgramViewerPanel<E> {
|
||||
fn panel_id(&self) -> gpui::dock::PanelId {
|
||||
PROGRAM_VIEWER
|
||||
|
||||
@@ -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<E: AppEngine> {
|
||||
explorer: Entity<ProjectExplorer<E>>,
|
||||
engine: Entity<E>,
|
||||
/// 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<u64>,
|
||||
}
|
||||
|
||||
impl<E: AppEngine> ProjectExplorerPanel<E> {
|
||||
/// Builds the explorer over `engine`'s project data.
|
||||
pub fn new(engine: Entity<E>, window: &mut Window, cx: &mut Context<Self>) -> 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<E: AppEngine> ProjectExplorerPanel<E> {
|
||||
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<u64>,
|
||||
position: Point<Pixels>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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<Self>) {
|
||||
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<E: AppEngine> PanelCommandHandler for ProjectExplorerPanel<E> {}
|
||||
|
||||
impl<E: AppEngine> Render for ProjectExplorerPanel<E> {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let colors = cx.default_colors().clone();
|
||||
@@ -79,6 +174,14 @@ impl<E: AppEngine> Render for ProjectExplorerPanel<E> {
|
||||
.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<E: AppEngine> Render for ProjectExplorerPanel<E> {
|
||||
.min_h_0()
|
||||
.child(self.explorer.clone()),
|
||||
)
|
||||
// The right-click popup renders anchored above the panel.
|
||||
.child(self.context_menu.widget())
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: AppEngine> EventEmitter<PanelEvent> for ProjectExplorerPanel<E> {}
|
||||
|
||||
impl<E: AppEngine> EventEmitter<ContextMenuTriggered> for ProjectExplorerPanel<E> {}
|
||||
|
||||
impl<E: AppEngine> DockPanel for ProjectExplorerPanel<E> {
|
||||
fn panel_id(&self) -> gpui::dock::PanelId {
|
||||
PROJECT
|
||||
@@ -122,3 +229,165 @@ impl<E: AppEngine> DockPanel for ProjectExplorerPanel<E> {
|
||||
.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<usize> = 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<usize> = 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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<E: AppEngine> {
|
||||
viewer: Entity<ViewerWidget<E::Clock>>,
|
||||
engine: Entity<E>,
|
||||
/// The source monitor's clock (also owned by the viewer widget; kept
|
||||
/// here so transport commands can read the playing state).
|
||||
clock: Entity<E::Clock>,
|
||||
/// 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<std::sync::Arc<gpui::RenderImage>>,
|
||||
/// The right-click context menu.
|
||||
context_menu: ContextMenuHandle,
|
||||
}
|
||||
|
||||
impl<E: AppEngine> SourceViewerPanel<E> {
|
||||
@@ -46,7 +55,7 @@ impl<E: AppEngine> SourceViewerPanel<E> {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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<E: AppEngine> SourceViewerPanel<E> {
|
||||
})
|
||||
.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<Self>) {
|
||||
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<Self>) -> 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<Self>) {
|
||||
@@ -102,6 +130,22 @@ impl<E: AppEngine> Render for SourceViewerPanel<E> {
|
||||
.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<E: AppEngine> Render for SourceViewerPanel<E> {
|
||||
.overflow_hidden()
|
||||
.child(self.viewer.clone()),
|
||||
)
|
||||
// The right-click popup renders anchored above the panel.
|
||||
.child(self.context_menu.widget())
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: AppEngine> PanelCommandHandler for SourceViewerPanel<E> {
|
||||
fn play_pause(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::PlayPause, cx)
|
||||
}
|
||||
fn prev_frame(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::PrevFrame, cx)
|
||||
}
|
||||
fn next_frame(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::NextFrame, cx)
|
||||
}
|
||||
fn go_to_start(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::GoToStart, cx)
|
||||
}
|
||||
fn go_to_end(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::GoToEnd, cx)
|
||||
}
|
||||
fn play_in_to_out(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::PlayInToOut, cx)
|
||||
}
|
||||
fn go_to_in(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::GoToIn, cx)
|
||||
}
|
||||
fn go_to_out(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::GoToOut, cx)
|
||||
}
|
||||
fn shuttle_left(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::ShuttleLeft, cx)
|
||||
}
|
||||
fn shuttle_stop(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::ShuttleStop, cx)
|
||||
}
|
||||
fn shuttle_right(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::ShuttleRight, cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: AppEngine> EventEmitter<PanelEvent> for SourceViewerPanel<E> {}
|
||||
|
||||
impl<E: AppEngine> EventEmitter<ContextMenuTriggered> for SourceViewerPanel<E> {}
|
||||
|
||||
impl<E: AppEngine> DockPanel for SourceViewerPanel<E> {
|
||||
fn panel_id(&self) -> gpui::dock::PanelId {
|
||||
SOURCE_VIEWER
|
||||
|
||||
+697
-3
@@ -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<E: AppEngine> {
|
||||
/// the cursor plus the start frame. `None` outside the clip area or while
|
||||
/// no footage drag is active.
|
||||
footage_drop: Option<FootageDropTarget>,
|
||||
/// 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<usize>,
|
||||
}
|
||||
|
||||
/// A footage drop target resolved from the cursor: the display track under
|
||||
@@ -165,6 +180,21 @@ impl<E: AppEngine> TimelinePanel<E> {
|
||||
})
|
||||
.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<E: AppEngine> TimelinePanel<E> {
|
||||
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<Pixels>,
|
||||
hit: TimelineHit,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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<Self>) {
|
||||
// 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<E: AppEngine> TimelinePanel<E> {
|
||||
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<Self>) -> 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<Self>) {
|
||||
let ids: Vec<ClipId> = self.timeline.read(cx).selection().iter().copied().collect();
|
||||
if ids.is_empty() {
|
||||
println!("[timeline] delete: nothing selected");
|
||||
return;
|
||||
}
|
||||
for id in ids {
|
||||
self.engine
|
||||
.update(cx, |engine, cx| engine.delete_clip(id, ripple, cx));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Self>) {
|
||||
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>) {
|
||||
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<Self>) {
|
||||
let current = self
|
||||
.engine
|
||||
.read(cx)
|
||||
.track(0)
|
||||
.map(|track| f32::from(track.height()))
|
||||
.unwrap_or(64.0);
|
||||
let next = (current + delta).clamp(24.0, 160.0);
|
||||
self.engine
|
||||
.update(cx, |engine, cx| engine.set_track_height(px(next), cx));
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: AppEngine> PanelCommandHandler for TimelinePanel<E> {
|
||||
// --- transport (the program monitor) ---
|
||||
fn play_pause(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::PlayPause, cx)
|
||||
}
|
||||
fn prev_frame(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::PrevFrame, cx)
|
||||
}
|
||||
fn next_frame(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::NextFrame, cx)
|
||||
}
|
||||
fn go_to_start(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::GoToStart, cx)
|
||||
}
|
||||
fn go_to_end(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::GoToEnd, cx)
|
||||
}
|
||||
fn play_in_to_out(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::PlayInToOut, cx)
|
||||
}
|
||||
fn go_to_in(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::GoToIn, cx)
|
||||
}
|
||||
fn go_to_out(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::GoToOut, cx)
|
||||
}
|
||||
fn shuttle_left(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::ShuttleLeft, cx)
|
||||
}
|
||||
fn shuttle_stop(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::ShuttleStop, cx)
|
||||
}
|
||||
fn shuttle_right(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.transport(ActionId::ShuttleRight, cx)
|
||||
}
|
||||
|
||||
// --- in / out points (the work area) ---
|
||||
fn set_in(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.set_point_at_playhead(true, cx);
|
||||
true
|
||||
}
|
||||
fn set_out(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.set_point_at_playhead(false, cx);
|
||||
true
|
||||
}
|
||||
fn reset_in(&mut self, cx: &mut Context<Self>) -> 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<Self>) -> 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<Self>) -> bool {
|
||||
self.engine.update(cx, |engine, cx| engine.clear_workarea(cx));
|
||||
true
|
||||
}
|
||||
|
||||
// --- selection ---
|
||||
fn select_all(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
let ids: Vec<ClipId> = {
|
||||
let engine = self.engine.read(cx);
|
||||
let mut ids = Vec::new();
|
||||
for index in 0..engine.track_count() {
|
||||
if let Some(track) = engine.track(index) {
|
||||
ids.extend(track.clips().iter().map(|clip| clip.id()));
|
||||
}
|
||||
}
|
||||
ids
|
||||
};
|
||||
self.timeline.update(cx, |view, cx| {
|
||||
view.state.select_range(ids.iter().copied());
|
||||
cx.notify();
|
||||
});
|
||||
self.engine
|
||||
.update(cx, |engine, cx| engine.set_selected_clips(ids, cx));
|
||||
true
|
||||
}
|
||||
fn deselect_all(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.timeline.update(cx, |view, cx| {
|
||||
view.state.select_range(std::iter::empty::<ClipId>());
|
||||
cx.notify();
|
||||
});
|
||||
self.engine
|
||||
.update(cx, |engine, cx| engine.set_selected_clips(Vec::new(), cx));
|
||||
true
|
||||
}
|
||||
|
||||
// --- editing ---
|
||||
fn delete_selected(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.delete_selection(false, cx);
|
||||
true
|
||||
}
|
||||
fn ripple_delete(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.delete_selection(true, cx);
|
||||
true
|
||||
}
|
||||
fn split_at_playhead(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.engine
|
||||
.update(cx, |engine, cx| engine.split_at_playhead(cx));
|
||||
true
|
||||
}
|
||||
fn set_marker(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.engine
|
||||
.update(cx, |engine, cx| engine.add_marker_at_playhead(cx));
|
||||
true
|
||||
}
|
||||
|
||||
// --- view ---
|
||||
fn zoom_in(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.zoom_timeline(1.25, cx);
|
||||
true
|
||||
}
|
||||
fn zoom_out(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.zoom_timeline(0.8, cx);
|
||||
true
|
||||
}
|
||||
fn increase_track_height(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.nudge_track_height(8.0, cx);
|
||||
true
|
||||
}
|
||||
fn decrease_track_height(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
self.nudge_track_height(-8.0, cx);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: AppEngine> Render for TimelinePanel<E> {
|
||||
@@ -402,6 +728,14 @@ impl<E: AppEngine> Render for TimelinePanel<E> {
|
||||
.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<E: AppEngine> Render for TimelinePanel<E> {
|
||||
)
|
||||
.child(right_controls),
|
||||
)
|
||||
// The right-click popup renders anchored above the panel.
|
||||
.child(self.context_menu.widget())
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: AppEngine> EventEmitter<PanelEvent> for TimelinePanel<E> {}
|
||||
|
||||
impl<E: AppEngine> EventEmitter<ContextMenuTriggered> for TimelinePanel<E> {}
|
||||
|
||||
impl<E: AppEngine> DockPanel for TimelinePanel<E> {
|
||||
fn panel_id(&self) -> gpui::dock::PanelId {
|
||||
TIMELINE
|
||||
@@ -452,6 +790,207 @@ impl<E: AppEngine> DockPanel for TimelinePanel<E> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<usize> = 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<usize> = 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<usize> = 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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 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<Vec<(Keystroke, usize)>> = 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<usize> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user