From 0f44588bbdd46e9c4fa043a5308f18dc13e749c0 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 16:43:40 +0700 Subject: [PATCH] Project panel: footage items' menu has Interpret Footage, Replace and Reload Footage (#290) Right-clicking imported footage offered no way to change its interpretation. As in After Effects, right-clicking an item now selects it, and footage items' menu has the File menu's footage commands: Interpret Footage (Main, Proxy, Remember / Apply Interpretation), Replace Footage, Reload Footage and Reveal in Explorer / Finder, with the menu bar's labels, shortcuts and enabled state (menus::submenu_at / entry_for). The panel's Interpret Footage button, which only ever said to select footage, opens the dialog for selected footage. Test: footage_context_menu_interprets_the_footage (ui_project_rows). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus.rs | 32 +++++++++++++++++++ crates/ui-egui/src/panels/project.rs | 18 +++++++++++ crates/ui-egui/tests/ui_project_rows.rs | 42 +++++++++++++++++++++++-- 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8fb22ba3..91765d7a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1668,6 +1668,38 @@ fn menu_nodes(app: &mut EffectcraftApp, ui: &mut egui::Ui, nodes: &[MenuNode], c } } +/// The menu bar's submenu at `path` (`["File", "Interpret Footage"]`) in a panel's context menu, +/// with the menu bar's labels, shortcuts and enabled state; a chosen entry goes to `clicked`. +pub(crate) fn submenu_at(app: &mut EffectcraftApp, ui: &mut egui::Ui, path: &[&str], clicked: &mut Option<(String, Value)>) { + let mut nodes = effectcraft_engine::menus::menu_bar(); + let mut found = None; + for label in path { + let Some(n) = nodes.iter().find(|n| matches!(n, MenuNode::Submenu { label: l, .. } if l == label)) else { return }; + if let MenuNode::Submenu { children, .. } = n { + nodes = children; + } + found = Some(n); + } + if let Some(n) = found { + menu_nodes(app, ui, std::slice::from_ref(n), clicked); + } + if let Some((_, p)) = clicked.as_mut() + && p.is_null() + { + *p = json!({}); + } +} + +/// The menu bar's entry for `command` in a panel's context menu (see [`submenu_at`]). +pub(crate) fn entry_for(app: &EffectcraftApp, ui: &mut egui::Ui, command: &str, clicked: &mut Option<(String, Value)>) { + if let Some((_, e)) = effectcraft_engine::menus::entries().into_iter().find(|(_, e)| e.command == command) + && menu_entry(app, ui, e) + { + *clicked = Some((e.command.clone(), e.params_or_empty())); + ui.close(); + } +} + /// Frontend state for dynamic menus (the current workspace and the saved ones). pub(crate) fn dyn_ctx<'a>(workspace: &'a str, saved_workspaces: &'a [String]) -> effectcraft_engine::menus::DynCtx<'a> { effectcraft_engine::menus::DynCtx { workspace: Some(workspace), saved_workspaces } diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 0c7b971b..7b14cce1 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -572,6 +572,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if resp.drag_started() { egui::DragAndDrop::set_payload(&ctx, DragPayload::Item(id.0)); } + // Right-clicking an item selects it (After Effects): the menu's File commands act on the + // selection. + if resp.secondary_clicked() && !selected { + app.session.state.project_selection = vec![*id]; + } + let footage = matches!(it.kind, ItemKind::Footage(_)); resp.context_menu(|ui| { if ui.button("Delete").clicked() { actions.push(("project.delete".into(), json!({"items": targets(app, *id, selected)}))); @@ -610,6 +616,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } }); + // Footage: the File menu's footage commands (Interpret Footage, Replace Footage…). + if footage { + let mut chosen = None; + crate::menus::submenu_at(app, ui, &["File", "Interpret Footage"], &mut chosen); + crate::menus::submenu_at(app, ui, &["File", "Replace Footage"], &mut chosen); + crate::menus::entry_for(app, ui, "file.reloadFootage", &mut chosen); + crate::menus::entry_for(app, ui, "file.revealInFinder", &mut chosen); + actions.extend(chosen); + } }); } // The scroll bars, over the rows (which would take their presses otherwise). @@ -680,9 +695,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let resp = widgets::icon_button(ui, r, icon, false, &t, egui::Id::new(("pfoot", id))).on_hover_text(tip); app.auto.add(&format!("project.{id}"), r, tip); if resp.clicked() { + let footage = + app.session.state.project_selection.first().and_then(|i| app.session.project.item(*i)).is_some_and(|i| matches!(i.kind, ItemKind::Footage(_))); match id { "newComp" => crate::panels::dialogs::open_new_comp(app), "newFolder" => actions.push(("project.newFolder".into(), json!({}))), + _ if footage => actions.push(("file.interpretFootage".into(), json!({}))), _ => app.ui.status = "Interpret Footage: select a footage item".into(), } } diff --git a/crates/ui-egui/tests/ui_project_rows.rs b/crates/ui-egui/tests/ui_project_rows.rs index f2a44fc5..43daf2ed 100644 --- a/crates/ui-egui/tests/ui_project_rows.rs +++ b/crates/ui-egui/tests/ui_project_rows.rs @@ -3,8 +3,8 @@ use effectcraft_engine::Session; use effectcraft_engine::color::Label; -use effectcraft_engine::project::{ItemId, ItemKind}; -use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_engine::project::{Footage, FootageKind, ItemId, ItemKind}; +use effectcraft_ui_egui::{Dialog, EffectcraftApp}; use egui::{Color32, Event, Modifiers, Pos2, Rect, pos2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; @@ -110,6 +110,44 @@ fn label_swatch_opens_the_label_menu() { assert!(h.query_by_label(&green).is_none(), "the menu closed after the choice"); } +/// Right-clicking a footage item selects it and offers the File menu's footage commands: +/// Interpret Footage ▸ Main... opens the Interpret Footage dialog on it (#290). A solid has none. +#[test] +fn footage_context_menu_interprets_the_footage() { + let (mut h, _, _, solid) = harness(); + let footage = Footage { kind: FootageKind::Video, path: "clip.mp4".into(), width: 64, height: 36, has_video: true, ..Default::default() }; + let clip = std::sync::Arc::make_mut(&mut h.state_mut().session.project).add_item("Clip", Default::default(), None, ItemKind::Footage(footage)); + h.run_steps(2); + let right_click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| { + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(2); + }; + let at = center(&h, &format!("project.item.{}.name", solid.0)); + right_click(&mut h, at); + assert_eq!(h.state().session.state.project_selection, vec![solid]); + assert!(h.query_by_label_contains("Interpret Footage").is_none(), "a solid isn't footage"); + h.input_mut().events.push(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + let at = center(&h, &format!("project.item.{}.name", clip.0)); + right_click(&mut h, at); + assert_eq!(h.state().session.state.project_selection, vec![clip], "right-click selects the item"); + let sub = h.query_by_label_contains("Interpret Footage").expect("Interpret Footage in the menu").rect(); + h.input_mut().events.push(Event::PointerMoved(sub.center())); + h.run_steps(3); + let main = h.query_by_label_contains("Main...").expect("Interpret Footage ▸ Main...").rect(); + for k in 0..=6 { + h.input_mut().events.push(Event::PointerMoved(pos2(main.center().x, sub.center().y + (main.center().y - sub.center().y) * k as f32 / 6.0))); + h.step(); + } + click_at(&mut h, main.center()); + assert_eq!(h.state().dialog, Some(Dialog::Form), "the Interpret Footage dialog opened"); +} + /// Dragging from the Project panel's empty area draws a selection box that selects the rows it /// touches; a click there deselects (#203). #[test]