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:
2026-08-18 17:15:00 +08:00
parent 6576113a69
commit 9daa266189
23 changed files with 4822 additions and 551 deletions
+1 -1
Submodule gpui updated: 6f75d3c92a...050d5ba22d
+558
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+400 -32
View File
@@ -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
View File
@@ -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() {
+81
View File
@@ -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()
}
}
+33
View File
@@ -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;
+411
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+342
View File
@@ -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,
}
}
+15 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
}
}
+1
View File
@@ -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
View File
@@ -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());
}
}
+86 -3
View File
@@ -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
+272 -3
View File
@@ -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,
]
);
}
}
+86 -2
View File
@@ -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
View File
@@ -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 (24160px).
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)));
}
}
-198
View File
@@ -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);
}
}