diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 72f10429..068ec00f 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1905,7 +1905,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { None => start_rename(&ctx, layer.id.0, &layer.name), } } - layer_context_menu(&row_resp, &comp, layer, &selected, vector_footage_layer(&app.session.project, layer), &mut actions); + layer_context_menu(&row_resp, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut actions); // Switches. let sws: [(Icon, bool, &str, bool); 8] = [ (Icon::Shy, sw.shy, "shy", true), @@ -2124,7 +2124,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { set_anchor(&ctx, cid, layer.id); } } - layer_context_menu(&body, &comp, layer, &selected, vector_footage_layer(&app.session.project, layer), &mut actions); + layer_context_menu(&body, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut actions); let drag_key = format!("bar-{}", layer.id.0); for (resp, kind) in [(&body, "move"), (&lin, "in"), (&lout, "out")] { if resp.dragged() { @@ -3516,20 +3516,42 @@ fn collapse_applies(layer: &Layer, project: &effectcraft_engine::project::Projec } } +/// Which Create ▸ conversions 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, +} + +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) } + } +} + /// A layer row's or bar's context menu. As in After Effects, right-clicking a layer that is part /// of the selection keeps the selection and the menu acts on all of it; right-clicking an /// unselected layer selects only that layer. -fn layer_context_menu(resp: &egui::Response, comp: &Comp, layer: &Layer, selected: &[LayerId], vector: bool, actions: &mut Vec<(String, serde_json::Value)>) { +fn layer_context_menu( + resp: &egui::Response, + comp: &Comp, + layer: &Layer, + selected: &[LayerId], + caps: LayerMenuCaps, + actions: &mut Vec<(String, serde_json::Value)>, +) { let in_selection = selected.contains(&layer.id); if resp.secondary_clicked() && !in_selection && !layer.switches.locked { 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] }; - crate::widgets::context_menu(resp, |ui| layer_menu(ui, &targets, vector, actions)); + crate::widgets::context_menu(resp, |ui| layer_menu(ui, &targets, caps, actions)); } /// Shared layer actions for Timeline rows and Composition context menus. -pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], vector: bool, actions: &mut Vec<(String, serde_json::Value)>) { +pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], caps: LayerMenuCaps, actions: &mut Vec<(String, serde_json::Value)>) { let Some(&id) = layers.first() else { return }; let mut item = |ui: &mut egui::Ui, label: &str, cmd: &str, params: serde_json::Value| { if ui.button(label).clicked() { @@ -3548,9 +3570,13 @@ pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], vector: bool, action item(ui, "Adjustment Layer", "layer.newAdjustment", json!({})); }); item(ui, "Layer Settings…", "layer.settings", json!({})); - if vector { + if caps.vector { item(ui, "Create Shapes from Vector Layer", "layer.create", json!({"op": "shapesFromVector", "layers": layers})); } + if caps.text { + item(ui, "Create Shapes from Text", "layer.create", json!({"op": "shapesFromText", "layers": layers})); + item(ui, "Create Masks from Text", "layer.create", json!({"op": "masksFromText", "layers": layers})); + } ui.separator(); ui.menu_button(crate::i18n::tr("Masks"), |ui| { item(ui, "New Mask", "layer.addMask", json!({"layer": id})); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index cbb842d9..d7fffe59 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1223,11 +1223,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if context_layers.is_empty() { ui.label(crate::i18n::tr("No unlocked layer selected")); } else { - let vector = context_layers - .iter() - .filter_map(|id| comp.layer(effectcraft_engine::project::LayerId(*id))) - .any(|layer| super::timeline::vector_footage_layer(&app.session.project, layer)); - super::timeline::layer_menu(ui, &context_layers, vector, &mut context_actions); + let caps = context_layers.iter().filter_map(|id| comp.layer(effectcraft_engine::project::LayerId(*id))).fold( + super::timeline::LayerMenuCaps::default(), + |mut caps, layer| { + let c = super::timeline::LayerMenuCaps::of(&app.session.project, layer); + caps.vector |= c.vector; + caps.text |= c.text; + caps + }, + ); + super::timeline::layer_menu(ui, &context_layers, caps, &mut context_actions); } ui.separator(); if ui.button(copy_frame).clicked() { diff --git a/crates/ui-egui/tests/ui_workflow_selection.rs b/crates/ui-egui/tests/ui_workflow_selection.rs index 697ff2a2..93af3a35 100644 --- a/crates/ui-egui/tests/ui_workflow_selection.rs +++ b/crates/ui-egui/tests/ui_workflow_selection.rs @@ -508,3 +508,47 @@ fn project_context_move_to_root_acts_on_the_whole_selection() { assert_eq!(project.item(effectcraft_engine::project::ItemId(*id)).unwrap().parent, None); } } + +/// A text layer's timeline context menu offers Create Shapes from Text and Create Masks from +/// Text (After Effects), which convert the layer; a non-text layer does not show them (#872). +#[test] +fn timeline_context_menu_creates_shapes_and_masks_from_text() { + use effectcraft_engine::project::LayerSource; + let mut s = Session::default(); + s.execute("comp.new", json!({"name":"Type","width":320,"height":180,"duration":4})).unwrap(); + s.execute("layer.newText", json!({"text":"Abc"})).unwrap(); + s.execute("layer.newSolid", json!({"name":"Plate","width":40,"height":40,"color":"#406080"})).unwrap(); + s.execute("edit.deselectAll", json!({})).unwrap(); + let text = s.active_comp().unwrap().layers.iter().find(|l| matches!(l.source, LayerSource::Text)).unwrap().id.0; + let plate = s.active_comp().unwrap().layers.iter().find(|l| l.name == "Plate").unwrap().id.0; + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + let row = |h: &Harness<'_, EffectcraftApp>, id: u64| { + let r = rect(h, &format!("timeline.layer.{id}.row")); + pos2(r.min.x + 210.0, r.center().y) + }; + let right_click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| { + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + }; + // The text layer offers both conversions; Create Shapes from Text works. + h.state_mut().session.execute("layer.select", json!({"layers": [text]})).unwrap(); + h.run_steps(3); + let at = row(&h, text); + right_click(&mut h, at); + h.get_by_label("Create Shapes from Text").click(); + h.run_steps(3); + let shapes = h.state().session.active_comp().unwrap().layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count(); + assert_eq!(shapes, 1, "a shape layer was created from the text"); + // A non-text layer does not show the text conversions. + h.state_mut().session.execute("layer.select", json!({"layers": [plate]})).unwrap(); + h.run_steps(3); + let at = row(&h, plate); + right_click(&mut h, at); + assert!(h.query_by_label("Create Shapes from Text").is_none(), "no text conversion for a solid"); + assert!(h.query_by_label("Create Masks from Text").is_none(), "no text conversion for a solid"); +}