mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
Layer: reveal layer sources in the Project panel (#1809)
This commit is contained in:
@@ -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!(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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…**
|
||||
|
||||
Reference in New Issue
Block a user