Create Shapes/Masks from Text in the layer context menu (#872)

The engine's layer.create has shapesFromText and masksFromText, but the
timeline and Composition context menus only offered Create Shapes from Vector
Layer, so text could not be converted to editable outlines from the UI. Gate
the two text conversions on a text layer (LayerMenuCaps replaces the lone
vector flag), beside the vector one.
This commit is contained in:
Vikram Bhaskaran committed 2026-10-13 01:47:19 +11:00
1 parent 9efe75330c
commit 3e900db974
3 files changed
+86 -11

No files matched your search

+32 -6
View File
@@ -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<u64> = 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}));
+10 -5
View File
@@ -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() {
@@ -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");
}