Layer: reveal layer sources in the Project panel (#1809)

This commit is contained in:
Camille Moine
2026-10-20 13:59:01 +02:00
parent ef74d82902
commit c7eb48dc3a
8 changed files with 168 additions and 13 deletions
+23 -7
View File
@@ -754,17 +754,33 @@ fn reveal_in_finder(s: &mut Session, p: &Value) -> Result<Value> {
}
fn reveal_source(s: &mut Session, p: &Value) -> Result<Value> {
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<ItemId> = 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<u64> = 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<Value> {
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<CommandSpec> {
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!(
+31
View File
@@ -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();
+12
View File
@@ -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<effectcraft_engine::project::ItemId> = 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::<Vec<_>>()})
}
// 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.
+33 -1
View File
@@ -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<ItemId> = 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::<Option<u64>>(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::<Option<u64>>(sel_key)) != Some(sel_now) {
ctx.data_mut(|d| d.insert_temp(sel_key, sel_now));
+20 -5
View File
@@ -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<u64> = 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());
+1
View File
@@ -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
},
);
@@ -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]
+10
View File
@@ -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…**