Merge pull request #3243 from vangel76/timeline-convert-bezier-menu-3237

Timeline: Convert To Bezier Path in the context menu of a parametric shape path
This commit is contained in:
FredApps authored and GitHub committed 2026-11-14 17:25:06 +01:00
commit 987be06ec3
2 files changed
+153 -2

No files matched your search

+70 -1
View File
@@ -3774,12 +3774,14 @@ fn group_row_click(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc:
let RowKind::Group { uid, name, fx, .. } = &rc.row.kind else { return };
let gr_rect = Rect::from_min_max(pos2(indent + 8.0, r.min.y), pos2(cw.switches, r.max.y));
// Effects, masks, shape items and animators drag to a new place among their siblings.
let movable = !layer.switches.locked && layer.props.find_group(*uid).is_some_and(PropGroup::is_instance);
let group = layer.props.find_group(*uid);
let movable = !layer.switches.locked && group.is_some_and(PropGroup::is_instance);
let gr = ui.interact(gr_rect, egui::Id::unique(("grow", uid)), if movable { Sense::click_and_drag() } else { Sense::click() });
if movable && gr.drag_started_by(egui::PointerButton::Primary) {
ui.ctx().data_mut(|d| d.insert_temp(group_drag_id(), GroupDrag { comp: cid, owner: gr.id, layer: layer.id, uid: *uid }));
}
app.auto.add(&format!("timeline.group.{uid}.name"), gr_rect, name);
shape_path_menu(&mut app.auto, &gr, layer, group, &mut *actions);
if gr.clicked() && uid & (WAVE_BIT | GEOM_BIT) == 0 {
// Shift or Cmd/Ctrl adds the group to the selection, or takes a selected one out, as
// property rows and Effect Controls headers do (#2552: several effects and styles for
@@ -3803,6 +3805,73 @@ fn group_row_click(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc:
}
}
/// The shape items Convert To Bezier Path turns into editable paths.
const PARAMETRIC_PATHS: [&str; 3] = ["rect", "ellipse", "star"];
/// The parametric paths (Rectangle, Ellipse, Polystar Path) inside shape group `g`, nested groups
/// included, that convert without losing anything: a keyframed or expression-driven one would
/// lose its animation, so the group leaves it alone.
fn static_parametric_paths(g: &PropGroup) -> Vec<u64> {
// Depth-first, without recursion; only groups and their contents hold paths.
let mut out = Vec::new();
let mut stack = vec![g];
while let Some(n) = stack.pop() {
for c in n.groups() {
match c.match_id.as_str() {
m if PARAMETRIC_PATHS.contains(&m) => {
if !c.any_animated() {
out.push(c.uid);
}
}
"group" | "contents" => stack.push(c),
_ => {}
}
}
}
out
}
/// Right-click on a Rectangle, Ellipse or Polystar Path row, or on a shape group holding such
/// paths: After Effects' Convert To Bezier Path, acting on that row (#3237). A path's own row
/// converts it even when animated (After Effects makes a static path at the current time); the
/// group's entry, an addition to After Effects, converts the static paths inside it.
fn shape_path_menu(
auto: &mut crate::automation::Registry,
resp: &egui::Response,
layer: &Layer,
group: Option<&PropGroup>,
actions: &mut Vec<(String, serde_json::Value)>,
) {
let Some(g) = group.filter(|_| matches!(layer.source, LayerSource::Shape)) else { return };
let uid = g.uid;
let items = if PARAMETRIC_PATHS.contains(&g.match_id.as_str()) {
vec![uid]
} else if g.match_id == "group" {
// Walked once, when the menu opens, not for every group row on every frame.
let key = egui::Id::unique(("tl-shape-path-menu", layer.id.0, uid));
if resp.secondary_clicked() {
let found = static_parametric_paths(g);
resp.ctx.data_mut(|d| d.insert_temp(key, found));
}
resp.ctx.data(|d| d.get_temp::<Vec<u64>>(key)).unwrap_or_default()
} else {
return;
};
if items.is_empty() {
return;
}
let (cmd, label) = ("path.convertToBezier", "Convert To Bezier Path");
let params = json!({"layer": layer.id.0, "items": items});
crate::widgets::context_menu(resp, |ui| {
let r = ui.add_enabled(!layer.switches.locked, egui::Button::new(crate::i18n::context_item(cmd, &params, label)));
auto.add(&format!("timeline.group.{uid}.menu.convertToBezier"), r.rect, label);
if r.clicked() {
actions.push((cmd.into(), params.clone()));
ui.close();
}
});
}
fn waveform_row(app: &mut EffectcraftApp, g: &Rw, rc: &RowCtx<'_>) {
let t = g.tl.t;
let graph_x0 = g.tl.graph_x0;
@@ -1,4 +1,5 @@
//! Right-click actions in the blank Project and Timeline areas, and on Timeline property names.
//! Right-click actions in the blank Project and Timeline areas, and on Timeline property and
//! shape path names.
use crate::gpu_test::RenderSerial;
use effectcraft_engine::Session;
use effectcraft_engine::project::ItemKind;
@@ -12,6 +13,8 @@ fn harness() -> Harness<'static, EffectcraftApp> {
let mut session = Session::default();
session.execute("comp.new", json!({"name":"Empty", "width":640,"height":360,"duration":4})).unwrap();
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(session));
// English whatever the system language: the menu items found below by label are translated.
h.state_mut().session.prefs.general.language = "en".into();
h.run_steps(3);
h
}
@@ -230,6 +233,85 @@ fn timeline_property_names_have_the_property_menu() {
assert_eq!(h.state().dialog, Some(Dialog::Form));
}
/// #3237: right-clicking a Rectangle Path row in the Timeline offers After Effects' Convert To
/// Bezier Path, which turns that path into an editable one; the shape group holding it offers the
/// same and converts the parametric paths inside it.
#[test]
fn timeline_parametric_shape_path_menu_converts_to_bezier() {
use effectcraft_engine::project::{LayerId, PropGroup};
let mut h = harness();
let layer = h.state_mut().session.execute("layer.newShape", json!({"kind": "rect", "size": [120, 80]})).unwrap()["layer"].as_u64().unwrap();
let contents =
|h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.sub("contents").unwrap().clone();
fn groups(g: &PropGroup, out: &mut Vec<(u64, String)>) {
out.push((g.uid, g.match_id.clone()));
for c in g.groups() {
groups(c, out);
}
}
let all = |h: &Harness<'_, EffectcraftApp>| {
let mut out = Vec::new();
groups(&contents(h), &mut out);
out
};
let find = |h: &Harness<'_, EffectcraftApp>, ids: &[&str]| all(h).into_iter().find(|(_, m)| ids.contains(&m.as_str())).map(|(u, _)| u);
let open = |h: &mut Harness<'_, EffectcraftApp>| {
h.state_mut().ui.timeline.open_layers.insert(layer);
for (uid, _) in all(&*h) {
h.state_mut().ui.timeline.open_groups.insert(uid);
}
h.run_steps(3);
};
let choose = |h: &mut Harness<'_, EffectcraftApp>, row: u64| {
let p = rect(h, &format!("timeline.group.{row}.name")).center();
right_click(h, p);
assert!(h.query_by_label("Convert To Bezier Path").is_some(), "the menu offers Convert To Bezier Path");
let p = rect(h, &format!("timeline.group.{row}.menu.convertToBezier")).center();
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(3);
};
let group = find(&h, &["group"]).expect("a shape group");
let path = find(&h, &["rect"]).expect("a Rectangle Path");
// The Rectangle Path's own row.
open(&mut h);
snapshot(&mut h, "shape-path-rows");
choose(&mut h, path);
assert!(find(&h, &["rect"]).is_none(), "the rectangle is converted");
assert!(find(&h, &["path"]).is_some(), "it is a bezier path now");
assert_eq!(h.state().session.history.undo.last().map(|u| u.0.as_str()), Some("Convert To Bezier Path"));
// A bezier path has nothing to convert: its row has no such entry.
let bezier = find(&h, &["path"]).unwrap();
open(&mut h);
let p = rect(&h, &format!("timeline.group.{bezier}.name")).center();
right_click(&mut h, p);
assert!(h.query_by_label("Convert To Bezier Path").is_none(), "a bezier path has no Convert To Bezier Path");
egui::Popup::close_all(&h.ctx);
// The shape group's row converts the rectangle inside it.
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(find(&h, &["rect"]), Some(path), "undo restores the rectangle");
open(&mut h);
choose(&mut h, group);
assert!(find(&h, &["rect"]).is_none(), "the group's rectangle is converted");
assert!(find(&h, &["path"]).is_some());
// A keyframed rectangle would lose its animation: the group leaves it out (and so has nothing
// to offer), while the path's own row still converts it, as After Effects does.
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
let size = contents(&h).find_group(path).unwrap().get("size").unwrap().uid;
h.state_mut().session.execute("prop.addKey", json!({"layer": layer, "path": format!("@{size}"), "time": 0.0, "value": [120, 80]})).unwrap();
open(&mut h);
let p = rect(&h, &format!("timeline.group.{group}.name")).center();
right_click(&mut h, p);
assert!(h.query_by_label("Convert To Bezier Path").is_none(), "nothing static to convert in the group");
egui::Popup::close_all(&h.ctx);
h.run_steps(3);
choose(&mut h, path);
assert!(find(&h, &["rect"]).is_none(), "the path's own row converts an animated rectangle");
}
/// The rows of a context menu touch: a gap between two entries highlighted neither and
/// swallowed a click there.
#[test]