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 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-09 16:43:40 +07:00
parent 2f3a507f8e
commit 0f44588bbd
3 changed files with 90 additions and 2 deletions

View File

@ -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 }

View File

@ -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(),
}
}

View File

@ -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]