Merge pull request #1046 from vbasky/fix/872-shapes-from-text

# Conflicts:
#	crates/ui-egui/tests/ui_workflow_selection.rs
This commit is contained in:
Brandon Thomas committed 2026-10-13 04:29:12 -04:00
commit 9f985ff374
3 files changed
+86 -11

No files matched your search

+32 -6
View File
@@ -2035,7 +2035,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),
@@ -2254,7 +2254,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);
// Ctrl/Cmd+Alt dragging a selected bar: Quick Offset (#1012), chosen as the
// drag starts and followed below the rows until the button is released.
@@ -3745,20 +3745,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() {
@@ -3777,9 +3799,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
@@ -1261,11 +1261,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() {
@@ -549,3 +549,47 @@ fn timeline_context_menu_precompose_groups_the_whole_selection() {
assert_eq!(nested, want, "via_bar={via_bar}: both selected layers are nested");
}
}
/// 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");
}