From c7eb48dc3aebc3c768b03eb9fe19212b382dc4c3 Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Tue, 20 Oct 2026 13:59:01 +0200 Subject: [PATCH] Layer: reveal layer sources in the Project panel (#1809) --- crates/engine/src/commands/layer_menu.rs | 30 +++++++++++---- crates/engine/src/tests_menu_cmds.rs | 31 +++++++++++++++ crates/ui-egui/src/menus.rs | 12 ++++++ crates/ui-egui/src/panels/project.rs | 34 ++++++++++++++++- crates/ui-egui/src/panels/timeline.rs | 25 +++++++++--- crates/ui-egui/src/panels/viewer.rs | 1 + .../ui-egui/tests/ui_blank_context_menus.rs | 38 +++++++++++++++++++ docs/footage.md | 10 +++++ 8 files changed, 168 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/commands/layer_menu.rs b/crates/engine/src/commands/layer_menu.rs index 9cac8041..cdc1dae4 100644 --- a/crates/engine/src/commands/layer_menu.rs +++ b/crates/engine/src/commands/layer_menu.rs @@ -754,17 +754,33 @@ fn reveal_in_finder(s: &mut Session, p: &Value) -> Result { } fn reveal_source(s: &mut Session, p: &Value) -> Result { - let item = source_item(s, p, "layer.revealSource")?; - s.state.project_selection = vec![item]; - frontend(s, "window.panel", &json!({"panel": "project"}))?; - Ok(json!({"item": item.0})) + let (cid, ids) = layers_p(s, p)?; + let mut items: Vec = Vec::new(); + { + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + for layer in ids.iter().filter_map(|id| comp.layer(*id)) { + if let Some(item) = layer.source.item() + && s.project.item(item).is_some() + && !items.contains(&item) + { + items.push(item); + } + } + } + if items.is_empty() { + return Err(bad("layer.revealSource", "the selected layers have no source item")); + } + s.state.project_selection = items.clone(); + let raw: Vec = items.iter().map(|item| item.0).collect(); + frontend(s, "project.reveal", &json!({"items": raw}))?; + Ok(json!({"item": items[0].0, "items": raw})) } fn reveal_comp(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; s.state.project_selection = vec![cid]; - frontend(s, "window.panel", &json!({"panel": "project"}))?; - Ok(json!({"item": cid.0})) + frontend(s, "project.reveal", &json!({"items": [cid.0]}))?; + Ok(json!({"item": cid.0, "items": [cid.0]})) } impl Session { @@ -909,7 +925,7 @@ pub fn specs() -> Vec { cmd!("layer.trackMatte", "Track Matte", [], None, "{layers?, op: none|alpha|alphaInverted|luma|lumaInverted|above|below}", has_layers, track_matte_op), cmd!("layer.openSource", "Open Layer Source", ["Layer"], None, "{layer?}", has_source_item, open_source), cmd!("layer.revealInFinder", "Reveal in Finder", ["Layer"], None, "{layer?}", has_footage_layer, reveal_in_finder), - cmd!("layer.revealSource", "Reveal Layer Source in Project", ["Layer", "Reveal"], None, "{layer?}", has_source_item, reveal_source), + cmd!("layer.revealSource", "Reveal Layer Source in Project", ["Layer", "Reveal"], None, "{layers?}", has_source_item, reveal_source), cmd!("comp.revealInProject", "Reveal Composition in Project", ["Layer", "Reveal"], None, "{comp?}", has_comp, reveal_comp), cmd!("layer.revealExpressionErrors", "Reveal Expression Errors", ["Layer", "Reveal"], None, "{comp?}", has_comp, reveal_expression_errors), cmd!( diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index 6da07dd4..fd19300d 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -725,6 +725,37 @@ fn reveal_properties() { assert!(frontend_events(&mut s).iter().all(|(c, _)| c == "timeline.revealProps")); } +#[test] +fn reveal_layer_source_selects_each_layers_source_item() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + let b = solid(&mut s, "#00ff00"); + let ia = layer(&s, a).source.item().unwrap(); + let ib = layer(&s, b).source.item().unwrap(); + assert_ne!(ia, ib); + s.execute("layer.select", json!({"layers": [a, b]})).unwrap(); + let r = s.execute("layer.revealSource", json!({})).unwrap(); + assert_eq!(r["item"], ia.0); + assert_eq!(r["items"], json!([ia.0, ib.0])); + assert_eq!(s.state.project_selection, vec![ia, ib]); + assert_eq!(frontend_events(&mut s), vec![("project.reveal".to_string(), json!({"items": [ia.0, ib.0]}))]); + // A null has no source. Alone it is refused; in a mixed selection only the sources are revealed. + let n = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layers": [n]})).unwrap(); + assert!(s.execute("layer.revealSource", json!({})).is_err()); + s.execute("layer.select", json!({"layers": [n, a]})).unwrap(); + let r = s.execute("layer.revealSource", json!({})).unwrap(); + assert_eq!(r["items"], json!([ia.0])); + assert_eq!(s.state.project_selection, vec![ia]); + // Reveal Composition in Project opens the same panel and folders. + let cid = s.active_comp_id().unwrap(); + s.drain_events(); + let r = s.execute("comp.revealInProject", json!({"comp": cid.0})).unwrap(); + assert_eq!(r["item"], cid.0); + assert_eq!(s.state.project_selection, vec![cid]); + assert_eq!(frontend_events(&mut s), vec![("project.reveal".to_string(), json!({"items": [cid.0]}))]); +} + #[test] fn sequence_layers() { let mut s = comp(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 92893dcb..36a85aef 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -939,6 +939,18 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu } // View ▸ New Viewer. "view.newViewer" => json!({"viewer": crate::panels::viewers::new_viewer(app)}), + // Reveal Layer Source / Reveal Composition: show the Project panel on the item, opening + // its folders and clearing the search so the row is visible. + "project.reveal" => { + let ids: Vec = p + .get("items") + .and_then(Value::as_array) + .map(|items| items.iter().filter_map(Value::as_u64).map(effectcraft_engine::project::ItemId).collect()) + .or_else(|| p.get("item").and_then(Value::as_u64).map(|item| vec![effectcraft_engine::project::ItemId(item)])) + .unwrap_or_default(); + crate::panels::project::reveal(app, ctx, &ids); + json!({"items": ids.iter().map(|item| item.0).collect::>()}) + } // Run by id (control channel, MCP): the Project panel's selection, whatever has focus. "project.deleteWithoutConfirmation" => crate::panels::delete_items::delete_confirmed(app, ctx, json!({}))?, // A gradient property's "Edit Gradient…" (the Timeline's value): the Gradient Editor. diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index c4e9894c..ef34798b 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -224,6 +224,11 @@ fn focused_row_id() -> egui::Id { egui::Id::new("project-focused-row") } +/// The item whose selection the panel last scrolled into view. +fn selection_seen_id() -> egui::Id { + egui::Id::new("proj-sel-seen") +} + /// The focused widget is one of the panel's rows, or nothing has keyboard focus: the panel's keys /// apply. Any other focused widget (a text field, a button reached with Tab) keeps them. fn keys_reach_rows(app: &EffectcraftApp, ctx: &egui::Context) -> bool { @@ -347,6 +352,33 @@ pub(crate) fn visible_rows(app: &EffectcraftApp) -> Vec<(ItemId, usize)> { rows } +/// Bring `items` into view: clear the panel's search, open every folder holding them, select +/// them, and show and focus the Project panel. Used by Reveal Layer Source in Project and +/// Reveal Composition in Project, so the item is visible even in a large, filtered project. +pub(crate) fn reveal(app: &mut EffectcraftApp, ctx: &egui::Context, items: &[ItemId]) { + let items: Vec = items.iter().copied().filter(|item| app.session.project.item(*item).is_some()).collect(); + if items.is_empty() { + return; + } + for item in &items { + let mut parent = app.session.project.item(*item).and_then(|item| item.parent); + let mut depth = 0; + while let Some(folder) = parent { + app.ui.project_open_folders.insert(folder.0); + parent = app.session.project.item(folder).and_then(|item| item.parent); + depth += 1; + if depth > 256 { + break; + } + } + } + app.ui.project_search.clear(); + app.session.state.project_selection = items; + // Force the panel's selection scroll on its next draw, even when the item was already selected. + ctx.data_mut(|d| d.remove_temp::>(selection_seen_id())); + app.show_panel(crate::dock::PanelKind::Project); +} + pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let p = ui.painter().clone(); @@ -562,7 +594,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } // A selection made elsewhere (a command, an agent, the viewer) scrolls into view. - let sel_key = egui::Id::new("proj-sel-seen"); + let sel_key = selection_seen_id(); let sel_now = app.session.state.project_selection.last().map(|i| i.0); if ctx.data(|d| d.get_temp::>(sel_key)) != Some(sel_now) { ctx.data_mut(|d| d.insert_temp(sel_key, sel_now)); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index b34fbd9d..44a24b14 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3883,18 +3883,25 @@ fn collapse_applies(layer: &Layer, project: &effectcraft_engine::project::Projec } } -/// Which Create ▸ conversions the layer context menu offers, from the targeted layer's kind. +/// Which Create ▸ conversions and Reveal ▸ actions the layer context menu offers, from the +/// targeted layer's kind. #[derive(Clone, Copy, Default)] pub(crate) struct LayerMenuCaps { /// SVG / PDF / AI / EPS footage: Create Shapes from Vector Layer. pub vector: bool, /// A text layer: Create Shapes from Text / Create Masks from Text. pub text: bool, + /// The layer has a source item to reveal in the Project panel. + pub source: bool, } impl LayerMenuCaps { pub(crate) fn of(project: &effectcraft_engine::project::Project, layer: &Layer) -> Self { - Self { vector: vector_footage_layer(project, layer), text: matches!(layer.source, LayerSource::Text) } + Self { + vector: vector_footage_layer(project, layer), + text: matches!(layer.source, LayerSource::Text), + source: layer.source.item().is_some_and(|item| project.item(item).is_some()), + } } } @@ -3916,6 +3923,10 @@ fn layer_context_menu( actions.push(("layer.select".into(), json!({"layers": [layer.id.0]}))); } let targets: Vec = if in_selection { comp.layers.iter().filter(|l| selected.contains(&l.id)).map(|l| l.id.0).collect() } else { vec![layer.id.0] }; + // The menu acts on the whole selection: offer Reveal when any target has a source, even if + // the layer under the pointer is a text, shape or null layer. + let any_source = targets.iter().any(|id| comp.layer(LayerId(*id)).is_some_and(|l| l.source.item().is_some())); + let caps = LayerMenuCaps { source: caps.source || any_source, ..caps }; crate::widgets::context_menu(resp, |ui| layer_menu(ui, &targets, caps, (auto, "timeline.layerMenu"), actions)); } @@ -3951,6 +3962,9 @@ pub(crate) fn layer_menu( item(ui, "Adjustment Layer", "layer.newAdjustment", json!({})); }); item(ui, "Layer Settings…", "layer.settings", json!({})); + if caps.source { + item(ui, "Reveal Layer Source in Project", "layer.revealSource", json!({"layers": layers})); + } if caps.vector { item(ui, "Create Shapes from Vector Layer", "layer.create", json!({"op": "shapesFromVector", "layers": layers})); } @@ -4656,20 +4670,21 @@ mod tests { /// the categories the Effect menu does; the actions it records stay the same. #[test] fn the_layer_context_menu_draws_in_the_interface_language() { - let caps = LayerMenuCaps { vector: true, text: false }; + let caps = LayerMenuCaps { vector: true, text: false, source: true }; let (mut auto, mut actions) = (crate::automation::Registry::default(), Vec::new()); crate::i18n::set_current("zh-hans"); let menu = drawn_text(|ui| layer_menu(ui, &[7], caps, (&mut auto, "t"), &mut actions)); let effects = drawn_text(|ui| effects_submenu(ui, &[7], &mut |_: &mut egui::Ui, _: &str, _: &str, _: serde_json::Value| {})); crate::i18n::set_current("en"); let english = drawn_text(|ui| layer_menu(ui, &[7], caps, (&mut auto, "t"), &mut actions)); - for label in ["新建", "图层设置...", "从矢量图层创建形状", "预合成...", "重复", "拆分图层", "删除"] { + for label in ["新建", "图层设置...", "在项目中显示图层源", "从矢量图层创建形状", "预合成...", "重复", "拆分图层", "删除"] + { assert!(menu.iter().any(|t| t == label), "{label} missing from {menu:?}"); } assert!(!menu.iter().any(|t| t.is_ascii()), "English left in {menu:?}"); assert!(effects.iter().any(|t| t == "模糊和锐化"), "{effects:?}"); assert!(!effects.iter().any(|t| t == "Blur & Sharpen"), "{effects:?}"); - for label in ["Layer Settings…", "Duplicate", "Delete"] { + for label in ["Layer Settings…", "Reveal Layer Source in Project", "Duplicate", "Delete"] { assert!(english.iter().any(|t| t == label), "{label} missing from {english:?}"); } assert!(actions.is_empty()); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 984ad696..61e7336f 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1311,6 +1311,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let c = super::timeline::LayerMenuCaps::of(&app.session.project, layer); caps.vector |= c.vector; caps.text |= c.text; + caps.source |= c.source; caps }, ); diff --git a/crates/ui-egui/tests/ui_blank_context_menus.rs b/crates/ui-egui/tests/ui_blank_context_menus.rs index 20f7467c..9e7363db 100644 --- a/crates/ui-egui/tests/ui_blank_context_menus.rs +++ b/crates/ui-egui/tests/ui_blank_context_menus.rs @@ -1,5 +1,6 @@ //! Right-click actions in the blank Project and Timeline areas, and on Timeline property names. use effectcraft_engine::Session; +use effectcraft_engine::project::ItemKind; use effectcraft_ui_egui::{Dialog, EffectcraftApp, dock::PanelKind}; use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; use egui_kittest::{Harness, kittest::Queryable as _}; @@ -299,6 +300,43 @@ fn timeline_layer_context_menu_has_layer_styles_submenu() { assert!(l.layer_styles().is_some_and(|g| g.sub("dropShadow").is_some())); } +/// #1809: Reveal Layer Source in Project shows the source in a large project: it clears the +/// Project panel's search, opens the folders holding the item and selects it. +#[test] +fn timeline_layer_context_menu_reveals_the_source_in_project() { + let mut h = harness(); + let layer = h.state_mut().session.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + let solid = h.state().session.project.items.values().find(|i| matches!(i.kind, ItemKind::Solid(_))).unwrap().id; + let outer = h.state_mut().session.execute("project.newFolder", json!({"name": "Assets"})).unwrap()["item"].as_u64().unwrap(); + let inner = h.state_mut().session.execute("project.newFolder", json!({"name": "Plates"})).unwrap()["item"].as_u64().unwrap(); + h.state_mut().session.execute("project.move", json!({"items": [inner], "folder": outer})).unwrap(); + h.state_mut().session.execute("project.move", json!({"items": [solid.0], "folder": inner})).unwrap(); + h.state_mut().session.execute("project.select", json!({"items": []})).unwrap(); + h.state_mut().ui.project_open_folders.clear(); + h.state_mut().ui.project_search = "no such item".into(); + h.run_steps(3); + assert!(h.state().auto.find(&format!("project.item.{}", solid.0)).is_none(), "the source starts hidden"); + let r = rect(&h, &format!("timeline.layer.{layer}.row")); + right_click(&mut h, pos2(r.min.x + 210.0, r.center().y)); + h.get_by_label("Reveal Layer Source in Project").click(); + h.run_steps(3); + assert_eq!(h.state().session.state.project_selection, vec![solid]); + assert_eq!(h.state().ui.focused, PanelKind::Project); + assert!(h.state().ui.project_search.is_empty(), "the search is cleared"); + assert!([outer, inner].iter().all(|folder| h.state().ui.project_open_folders.contains(folder)), "the folders holding the source open"); + assert!(h.state().auto.find(&format!("project.item.{}", solid.0)).is_some(), "the source row is visible"); + // In a mixed selection the command still appears when the right-clicked layer has no source, + // because the menu acts on the solid layer too. + let shape = h.state_mut().session.execute("layer.newShape", json!({"kind": "none"})).unwrap()["layer"].as_u64().unwrap(); + h.state_mut().session.execute("layer.select", json!({"layers": [shape, layer]})).unwrap(); + h.run_steps(3); + let r = rect(&h, &format!("timeline.layer.{shape}.row")); + right_click(&mut h, pos2(r.min.x + 210.0, r.center().y)); + h.get_by_label("Reveal Layer Source in Project").click(); + h.run_steps(3); + assert_eq!(h.state().session.state.project_selection, vec![solid], "the solid's source, not the shape"); +} + /// #976: a layer's context menu has After Effects' Markers ▸ Add Marker, which marks the layer that /// was right-clicked (not the one selected before, and not the composition) at the current time. #[test] diff --git a/docs/footage.md b/docs/footage.md index 84d4c49e..660f8ac3 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -70,6 +70,16 @@ next to it. Agents: `file.importProject {"path": "/projects/titles.ecproj"}` returns the comps, the root comps, `primaryComp` (the only root comp, if there is one) and any missing media. +## Finding a layer's source in the Project panel + +Right-click a layer in the Timeline or Composition panel and choose **Reveal Layer Source in +Project** (also Layer ▸ Reveal ▸ Reveal Layer Source in Project). The Project panel opens, its +search is cleared, the folders holding the source open, and the source item is selected. + +Layers whose image is generated inside the composition — text, shape, null, camera, light and +3D primitive layers — have no Project item, so the command is not offered for them. Adjustment +layers do have one: the solid that backs them. + ## Interpret Footage Select a footage item in the Project panel and choose **File ▸ Interpret Footage ▸ Main…**