mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-15 00:04:59 +00:00
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:
@@ -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, ¶ms, 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]
|
||||
|
||||
Reference in New Issue
Block a user