mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-01 05:58:19 -06:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
@@ -56,6 +56,10 @@ fn compatible(a: &Property, b: &effectcraft_keyframe::Value) -> bool {
|
||||
a.value.kind_name() == b.kind_name() && a.value.components().len() == b.components().len()
|
||||
}
|
||||
|
||||
pub(crate) fn locked_selection(comp: &effectcraft_project::Comp, selected: &[KeyRef]) -> Vec<KeyRef> {
|
||||
selected.iter().filter(|k| comp.layer(k.layer).is_some_and(|l| l.switches.locked)).copied().collect()
|
||||
}
|
||||
|
||||
/// Paste copied keyframes at the current time into the target layers' properties.
|
||||
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let clip = s.state.key_clipboard.clone();
|
||||
@@ -90,6 +94,9 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut plan: Vec<(LayerId, Uid, usize)> = vec![];
|
||||
for (ti, lid) in targets.iter().enumerate() {
|
||||
let Some(l) = comp.layer(*lid) else { continue };
|
||||
if l.switches.locked {
|
||||
continue;
|
||||
}
|
||||
for (ci, c) in clip.iter().enumerate() {
|
||||
if paired && sources.get(ti) != Some(&c.layer) {
|
||||
continue;
|
||||
@@ -111,11 +118,12 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
if plan.is_empty() {
|
||||
return Err(bad("keys.paste", "no compatible property to paste into (select a property of the same type)"));
|
||||
return Err(bad("keys.paste", "no compatible unlocked property to paste into (unlock the target or select a property of the same type)"));
|
||||
}
|
||||
s.edit("Paste Keyframes", merge_p(p), |proj, st| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let mut sel = vec![];
|
||||
let mut sel = locked_selection(comp, &st.selected_keys);
|
||||
let retained = sel.len();
|
||||
for (lid, uid, ci) in &plan {
|
||||
let Some(l) = comp.layer_mut(*lid) else { continue };
|
||||
let times: Vec<Tick> = clip[*ci].keys.iter().map(|k| l.layer_time(t + k.time)).collect();
|
||||
@@ -137,8 +145,9 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
retime_roving(&mut pr.keys, pr.spatial);
|
||||
}
|
||||
let pasted = sel.len().saturating_sub(retained);
|
||||
st.selected_keys = sel;
|
||||
Ok(json!(st.selected_keys.len()))
|
||||
Ok(json!(pasted))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -176,6 +185,9 @@ pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, L
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let t = time_p(s, p, Some(comp));
|
||||
let target = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
if target.switches.locked {
|
||||
return Err(bad("keys.paste", "the target layer is locked"));
|
||||
}
|
||||
let mut order: Vec<usize> = (0..path.vertices.len()).collect();
|
||||
if path.closed && !order.is_empty() {
|
||||
order.push(0);
|
||||
@@ -203,6 +215,9 @@ pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, L
|
||||
})
|
||||
.collect();
|
||||
s.edit("Paste Keyframes", merge_p(p), |proj, st| {
|
||||
let comp = proj.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let mut selected = locked_selection(comp, &st.selected_keys);
|
||||
let retained = selected.len();
|
||||
let l = super::layer_mut(proj, cid, lid)?;
|
||||
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.paste", "no property"))?;
|
||||
let mut sel = vec![];
|
||||
@@ -217,8 +232,10 @@ pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, L
|
||||
// Re-timing keeps the keys' order, so the pasted keys are found again by index.
|
||||
let pasted: Vec<usize> = pr.keys.iter().enumerate().filter(|(_, k)| sel.contains(&k.time)).map(|(i, _)| i).collect();
|
||||
retime_roving(&mut pr.keys, pr.spatial);
|
||||
st.selected_keys = pasted.iter().filter_map(|&i| pr.keys.get(i)).map(|k| KeyRef { layer: lid, prop: uid, time: k.time }).collect();
|
||||
Ok(json!(st.selected_keys.len()))
|
||||
selected.extend(pasted.iter().filter_map(|&i| pr.keys.get(i)).map(|k| KeyRef { layer: lid, prop: uid, time: k.time }));
|
||||
let count = selected.len().saturating_sub(retained);
|
||||
st.selected_keys = selected;
|
||||
Ok(json!(count))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -297,6 +314,9 @@ fn set_key(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let base = KeyTimeBase::from_params(p, "keys.set")?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
if layer.switches.locked {
|
||||
return Err(bad("keys.set", "the layer is locked"));
|
||||
}
|
||||
let t = key_time(layer, comp.frame_rate, p, "time", base, "keys.set", false)?.ok_or_else(|| bad("keys.set", "missing `time` (layer time by default)"))?;
|
||||
let new_t = key_time(layer, comp.frame_rate, p, "newTime", base, "keys.set", true)?;
|
||||
let value = p.get("value").cloned();
|
||||
@@ -350,6 +370,9 @@ fn set_ease(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let influence = f_p(p, "influence").map(|v| (v / 100.0).clamp(0.001, 1.0));
|
||||
s.edit("Keyframe Velocity", merge_p(p), |proj, _| {
|
||||
let l = super::layer_mut(proj, cid, lid)?;
|
||||
if l.switches.locked {
|
||||
return Err(bad("keys.setEase", "the layer is locked"));
|
||||
}
|
||||
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.setEase", "no property"))?;
|
||||
let i = nearest_key(&pr.keys, t).ok_or_else(|| bad("keys.setEase", "no keyframes"))?;
|
||||
let spatial = pr.spatial;
|
||||
@@ -496,7 +519,13 @@ fn time_reverse(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
let mut new_sel = vec![];
|
||||
for ((lid, uid), times) in groups {
|
||||
let Some(pr) = comp.layer_mut(lid).and_then(|l| l.props.find_mut(uid)) else { continue };
|
||||
let Some(layer) = comp.layer_mut(lid) else { continue };
|
||||
// A locked layer's keys stay as they are, and stay selected.
|
||||
if layer.switches.locked {
|
||||
new_sel.extend(times.iter().map(|time| KeyRef { layer: lid, prop: uid, time: *time }));
|
||||
continue;
|
||||
}
|
||||
let Some(pr) = layer.props.find_mut(uid) else { continue };
|
||||
let (mut picked, rest): (Vec<Keyframe>, Vec<Keyframe>) = pr.keys.drain(..).partition(|k| times.contains(&k.time));
|
||||
effectcraft_keyframe::time_reverse(&mut picked);
|
||||
new_sel.extend(picked.iter().map(|k| KeyRef { layer: lid, prop: uid, time: k.time }));
|
||||
|
||||
@@ -213,7 +213,8 @@ fn sample_curve(
|
||||
|
||||
fn grouped_selection(s: &Session) -> std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> {
|
||||
let mut g: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
|
||||
for k in &s.state.selected_keys {
|
||||
let Some(comp) = s.active_comp() else { return g };
|
||||
for k in super::prop::editable_keys(comp, &s.state.selected_keys) {
|
||||
g.entry((k.layer, k.prop)).or_default().push(k.time);
|
||||
}
|
||||
for v in g.values_mut() {
|
||||
@@ -273,7 +274,7 @@ fn run_on_selection(
|
||||
s.edit(label, None, |proj, st| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let fr = comp.frame_rate;
|
||||
let mut sel = vec![];
|
||||
let mut sel = super::anim::locked_selection(comp, &st.selected_keys);
|
||||
for ((lid, uid), times) in groups {
|
||||
let Some(l) = comp.layer_mut(lid) else { continue };
|
||||
let lc = l.clone();
|
||||
|
||||
@@ -271,7 +271,8 @@ fn add_keyframe(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
/// Selected keys grouped by (layer, property).
|
||||
fn keys_by_prop(s: &Session) -> BTreeMap<(LayerId, Uid), Vec<Tick>> {
|
||||
let mut m: BTreeMap<(LayerId, Uid), Vec<Tick>> = BTreeMap::new();
|
||||
for k in &s.state.selected_keys {
|
||||
let Some(comp) = s.active_comp() else { return m };
|
||||
for k in super::prop::editable_keys(comp, &s.state.selected_keys) {
|
||||
m.entry((k.layer, k.prop)).or_default().push(k.time);
|
||||
}
|
||||
m
|
||||
|
||||
@@ -72,6 +72,10 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut new_sel = vec![];
|
||||
for ((lid, uid), times) in groups {
|
||||
let Some(layer) = comp.layer_mut(lid) else { continue };
|
||||
if layer.switches.locked {
|
||||
new_sel.extend(times.into_iter().map(|time| KeyRef { layer: lid, prop: uid, time }));
|
||||
continue;
|
||||
}
|
||||
let (start, stretch) = (layer.start_time, layer.stretch);
|
||||
let to_comp = |lt: Tick| if (stretch - 100.0).abs() < 1e-9 { start + lt } else { start + Tick((lt.0 as f64 * stretch / 100.0) as i64) };
|
||||
let to_layer = |ct: Tick| {
|
||||
@@ -122,6 +126,9 @@ fn spatial_tangents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let brk = b_p(p, "break").unwrap_or(false);
|
||||
s.edit("Edit Spatial Tangents", merge_p(p), |proj, _| {
|
||||
let l = super::layer_mut(proj, cid, lid)?;
|
||||
if l.switches.locked {
|
||||
return Err(bad("keys.setSpatialTangents", "the layer is locked"));
|
||||
}
|
||||
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.setSpatialTangents", "no property"))?;
|
||||
if !pr.spatial {
|
||||
return Err(bad("keys.setSpatialTangents", "not a spatial property"));
|
||||
|
||||
@@ -37,6 +37,8 @@ pub(crate) fn label_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("keys.label", "select keyframes first"));
|
||||
}
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let keys = super::prop::editable_keys(comp, &keys);
|
||||
let n = s.edit("Keyframe Label", None, |proj, _| {
|
||||
let mut n = 0;
|
||||
for k in &keys {
|
||||
|
||||
@@ -438,20 +438,22 @@ fn interpolate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// The layer: given, else the layer of selected Mask Path keys, else the selection.
|
||||
let cid = super::comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let selected = super::prop::editable_keys(comp, &s.state.selected_keys);
|
||||
let lid = match p.get("layer") {
|
||||
Some(v) => super::resolve_layer(comp, v).ok_or_else(|| bad(CMD, format!("no layer {v}")))?,
|
||||
None => match s.state.selected_keys.iter().find(|k| comp.layer(k.layer).is_some_and(|l| l.masks().is_some_and(|m| m.find(k.prop).is_some()))) {
|
||||
None => match selected.iter().find(|k| comp.layer(k.layer).is_some_and(|l| l.masks().is_some_and(|m| m.find(k.prop).is_some()))) {
|
||||
Some(k) => k.layer,
|
||||
None => layer_p(s, p, CMD)?.1,
|
||||
},
|
||||
};
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
if !super::prop::key_layer_editable(comp, lid) {
|
||||
return Err(bad(CMD, "the layer is locked"));
|
||||
}
|
||||
let masks = layer.masks().ok_or_else(|| bad(CMD, "the layer has no masks"))?;
|
||||
let uid = match p.get("mask") {
|
||||
Some(_) => mask_uid(s, layer, p),
|
||||
None => s
|
||||
.state
|
||||
.selected_keys
|
||||
None => selected
|
||||
.iter()
|
||||
.filter(|k| k.layer == lid)
|
||||
.find_map(|k| masks.groups().find(|g| g.get("path").is_some_and(|pr| pr.uid == k.prop)).map(|g| g.uid))
|
||||
@@ -466,7 +468,7 @@ fn interpolate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ts: Vec<Tick> = a.iter().filter_map(Value::as_f64).map(|t| layer.layer_time(comp.frame_rate.snap_nearest(Tick::from_seconds_f64(t)))).collect();
|
||||
ts.into_iter().filter_map(|t| path_prop.keys.iter().find(|k| (k.time.0 - t.0).abs() <= comp.frame_duration().0 / 2).map(|k| k.time)).collect()
|
||||
}
|
||||
None => s.state.selected_keys.iter().filter(|k| k.layer == lid && k.prop == path_prop.uid).map(|k| k.time).collect(),
|
||||
None => selected.iter().filter(|k| k.layer == lid && k.prop == path_prop.uid).map(|k| k.time).collect(),
|
||||
};
|
||||
times.sort();
|
||||
times.dedup();
|
||||
|
||||
@@ -914,6 +914,15 @@ fn select_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// References that can be edited, including explicit key lists supplied by commands.
|
||||
pub(crate) fn editable_keys(comp: &effectcraft_project::Comp, selected: &[KeyRef]) -> Vec<KeyRef> {
|
||||
selected.iter().filter(|key| key_layer_editable(comp, key.layer)).copied().collect()
|
||||
}
|
||||
|
||||
pub(crate) fn key_layer_editable(comp: &effectcraft_project::Comp, layer: LayerId) -> bool {
|
||||
comp.layer(layer).is_some_and(|layer| !layer.switches.locked)
|
||||
}
|
||||
|
||||
/// Apply `f` to every selected key; keeps the selection pointing at moved keys.
|
||||
pub(crate) fn edit_keys(s: &mut Session, label: &str, merge: Option<&str>, f: impl Fn(&mut Vec<Keyframe>, usize, &mut Tick) -> bool) -> Result<Value> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
@@ -926,7 +935,16 @@ pub(crate) fn edit_keys(s: &mut Session, label: &str, merge: Option<&str>, f: im
|
||||
groups.entry((k.layer, k.prop)).or_default().push(k.time);
|
||||
}
|
||||
for ((lid, uid), times) in groups {
|
||||
let Some(pr) = comp.layer_mut(lid).and_then(|l| l.props.find_mut(uid)) else { continue };
|
||||
let Some(layer) = comp.layer_mut(lid) else { continue };
|
||||
// A locked layer's keys stay as they are, and stay selected.
|
||||
if layer.switches.locked {
|
||||
for time in times {
|
||||
let r = KeyRef { layer: lid, prop: uid, time };
|
||||
remapped.insert(r, r);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(pr) = layer.props.find_mut(uid) else { continue };
|
||||
// Process each stored key once, in an order that avoids collisions when moving.
|
||||
let mut idx: Vec<(usize, Tick)> = times.iter().filter_map(|t| key_at(&pr.keys, *t).map(|i| (i, *t))).collect();
|
||||
idx.sort_by_key(|x| x.0);
|
||||
@@ -1079,6 +1097,10 @@ fn delete_keys(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let mut removed = 0usize;
|
||||
let mut changed = std::collections::BTreeSet::new();
|
||||
for k in &sel {
|
||||
// A layer locked after its keys were selected keeps them (the selection stays too).
|
||||
if comp.layer(k.layer).is_some_and(|l| l.switches.locked) {
|
||||
continue;
|
||||
}
|
||||
if let Some(pr) = comp.layer_mut(k.layer).and_then(|l| l.props.find_mut(k.prop))
|
||||
&& let Some(i) = key_at(&pr.keys, k.time)
|
||||
{
|
||||
@@ -1092,7 +1114,7 @@ fn delete_keys(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
st.selected_keys.clear();
|
||||
st.selected_keys.retain(|k| comp.layer(k.layer).is_some_and(|l| l.switches.locked));
|
||||
for (layer, prop) in changed {
|
||||
sync_polystar_property(proj, st, cid, layer, prop, time)?;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,80 @@ fn select(s: &mut crate::Session, layer: u64, path: &str) {
|
||||
s.execute("prop.select", json!({"layer": layer, "path": path})).unwrap();
|
||||
}
|
||||
|
||||
fn selection_command_skips_locked_keys(command: &str, path: &str, values: [serde_json::Value; 3], mut params: serde_json::Value) {
|
||||
let (mut s, locked) = setup();
|
||||
let unlocked = s.execute("layer.newSolid", json!({"name": "Editable", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
|
||||
for layer in [locked, unlocked] {
|
||||
animate(&mut s, layer, path, &[(0.0, values[0].clone()), (0.5, values[1].clone()), (1.0, values[2].clone())]);
|
||||
}
|
||||
let before = prop(&s, locked, path).keys;
|
||||
let selected: Vec<_> = [locked, unlocked]
|
||||
.into_iter()
|
||||
.flat_map(|layer| {
|
||||
let pr = prop(&s, layer, path);
|
||||
pr.keys.into_iter().map(move |key| crate::KeyRef { layer: effectcraft_project::LayerId(layer), prop: pr.uid, time: key.time })
|
||||
})
|
||||
.collect();
|
||||
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": true})).unwrap();
|
||||
s.state.selected_keys = selected.clone();
|
||||
if params.get("keys").is_some() {
|
||||
params["keys"] = serde_json::to_value(&selected).unwrap();
|
||||
}
|
||||
s.execute_checked(command, params.clone()).unwrap();
|
||||
assert_eq!(prop(&s, locked, path).keys, before, "{command} changed a locked layer");
|
||||
let after = prop(&s, unlocked, path).keys;
|
||||
assert_ne!(after, before, "{command} must still edit unlocked keys");
|
||||
let after_selection = s.state.selected_keys.clone();
|
||||
assert!(selected.iter().filter(|k| k.layer.0 == locked).all(|key| after_selection.contains(key)));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(prop(&s, unlocked, path).keys, before);
|
||||
assert!(selected.iter().filter(|key| key.layer.0 == locked).all(|key| s.state.selected_keys.contains(key)));
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(prop(&s, unlocked, path).keys, after);
|
||||
assert_eq!(s.state.selected_keys, after_selection);
|
||||
// All-locked input must preserve the selection and document as well.
|
||||
s.state.selected_keys.retain(|k| k.layer.0 == locked);
|
||||
if params.get("keys").is_some() {
|
||||
params["keys"] = serde_json::to_value(&s.state.selected_keys).unwrap();
|
||||
}
|
||||
let locked_selection = s.state.selected_keys.clone();
|
||||
let project = serde_json::to_value(&*s.project).unwrap();
|
||||
let _ = s.execute_checked(command, params);
|
||||
assert_eq!(serde_json::to_value(&*s.project).unwrap(), project);
|
||||
assert_eq!(s.state.selected_keys, locked_selection);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiggler_preserves_locked_keys_in_mixed_selection() {
|
||||
selection_command_skips_locked_keys(
|
||||
"keys.wiggle",
|
||||
"transform/opacity",
|
||||
[json!(10), json!(90), json!(20)],
|
||||
json!({"apply": "temporal", "frequency": 8, "magnitude": 10}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoother_preserves_locked_keys_in_mixed_selection() {
|
||||
selection_command_skips_locked_keys("keys.smooth", "transform/opacity", [json!(10), json!(50), json!(90)], json!({"tolerance": 1}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exponential_scale_preserves_locked_keys_in_mixed_selection() {
|
||||
selection_command_skips_locked_keys(
|
||||
"keys.exponentialScale",
|
||||
"transform/scale",
|
||||
[json!([100, 100, 100]), json!([150, 150, 150]), json!([400, 400, 400])],
|
||||
json!({}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_label_preserves_locked_keys_in_mixed_selection() {
|
||||
selection_command_skips_locked_keys("edit.label", "transform/opacity", [json!(10), json!(50), json!(90)], json!({"label": "Red"}));
|
||||
selection_command_skips_locked_keys("edit.label", "transform/opacity", [json!(10), json!(50), json!(90)], json!({"label": "Red", "keys": true}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiggle_adds_keys_at_frequency_within_magnitude() {
|
||||
let (mut s, l) = setup();
|
||||
|
||||
@@ -43,3 +43,207 @@ fn delete_keys_reports_zero_when_no_selected_key_exists() {
|
||||
assert_eq!(removed, json!(0));
|
||||
assert_eq!(keys_of(&s, layer, prop), keys);
|
||||
}
|
||||
|
||||
/// A layer locked after its keys were selected keeps them: Delete and Edit Value leave them alone.
|
||||
#[test]
|
||||
fn a_locked_layers_keys_cannot_be_deleted_or_edited() {
|
||||
let (mut s, layer, prop) = setup();
|
||||
let keys = keys_of(&s, layer, prop);
|
||||
s.state.selected_keys = keys.clone();
|
||||
s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap();
|
||||
let removed = s.execute_checked("keys.delete", json!({})).unwrap();
|
||||
assert_eq!(removed, json!(0));
|
||||
assert_eq!(keys_of(&s, layer, prop), keys);
|
||||
assert_eq!(s.state.selected_keys, keys, "the selection stays");
|
||||
let e = s.execute("keys.set", json!({"layer": layer, "prop": prop, "time": 0, "value": 90})).unwrap_err().to_string();
|
||||
assert!(e.contains("locked"), "{e}");
|
||||
let value = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys[0].value.as_f64();
|
||||
assert_eq!(value, 0.0);
|
||||
// Unlocked again, both work.
|
||||
s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap();
|
||||
s.execute_checked("keys.set", json!({"layer": layer, "prop": prop, "time": 0, "value": 90})).unwrap();
|
||||
assert_eq!(s.execute_checked("keys.delete", json!({})).unwrap(), json!(3));
|
||||
}
|
||||
|
||||
/// Easy Ease, Toggle Hold and Time-Reverse leave a locked layer's keys as they are and keep them
|
||||
/// selected; unlocked, they change them.
|
||||
#[test]
|
||||
fn selection_based_key_edits_skip_a_locked_layer() {
|
||||
let (mut s, layer, prop) = setup();
|
||||
let keys = keys_of(&s, layer, prop);
|
||||
let stored = |s: &Session| s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys.clone();
|
||||
let before = stored(&s);
|
||||
s.state.selected_keys = keys.clone();
|
||||
s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap();
|
||||
for command in ["keys.easyEase", "keys.toggleHold", "keys.timeReverse"] {
|
||||
s.execute_checked(command, json!({})).unwrap();
|
||||
assert_eq!(stored(&s), before, "{command} changed a locked layer's keys");
|
||||
assert_eq!(s.state.selected_keys, keys, "{command} dropped the selection of a locked layer's keys");
|
||||
}
|
||||
s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap();
|
||||
s.execute_checked("keys.easyEase", json!({})).unwrap();
|
||||
assert_ne!(stored(&s), before, "unlocked, Easy Ease changes the keys");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spatial_handles_cannot_edit_a_locked_layer() {
|
||||
let (mut s, layer, _) = setup();
|
||||
for (time, value) in [(0, [10, 20, 0]), (1, [80, 60, 0])] {
|
||||
s.execute("prop.addKey", json!({"layer": layer, "path": "transform/position", "time": time, "value": value})).unwrap();
|
||||
}
|
||||
let prop = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/position").unwrap().uid;
|
||||
s.execute("keys.select", json!({"keys": [{"layer": layer, "prop": prop, "time": 0}]})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap();
|
||||
let before = s.project.to_json();
|
||||
let selected = s.state.selected_keys.clone();
|
||||
let undo_len = s.history.undo.len();
|
||||
let params = json!({"layer": layer, "prop": prop, "time": 0, "out": [15, 25, 0]});
|
||||
let error = s.execute("keys.setSpatialTangents", params.clone()).unwrap_err().to_string();
|
||||
assert!(error.contains("locked"), "{error}");
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
assert_eq!(s.state.selected_keys, selected);
|
||||
assert_eq!(s.history.undo.len(), undo_len);
|
||||
s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap();
|
||||
let unlocked = s.project.to_json();
|
||||
s.execute("keys.setSpatialTangents", params).unwrap();
|
||||
let edited = s.project.to_json();
|
||||
assert_ne!(edited, unlocked);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), unlocked);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), edited);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_keys_or_motion_paths_cannot_edit_locked_targets() {
|
||||
for path_clip in [false, true] {
|
||||
for explicit in [false, true] {
|
||||
let (mut s, layer, opacity) = setup();
|
||||
let target_path = if path_clip { "transform/position" } else { "transform/opacity" };
|
||||
let source = if path_clip {
|
||||
s.execute("mask.new", json!({"layer": layer, "vertices": [[0, 0], [20, 10], [40, 0]], "closed": false})).unwrap();
|
||||
s.execute("prop.toggleAnimation", json!({"layer": layer, "path": "masks/#1/path"})).unwrap();
|
||||
s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("masks/#1/path").unwrap().uid
|
||||
} else {
|
||||
opacity
|
||||
};
|
||||
s.execute("keys.select", json!({"keys": [{"layer": layer, "prop": source, "time": 0}]})).unwrap();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": layer, "path": target_path})).unwrap();
|
||||
s.execute("time.set", json!({"time": 3})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap();
|
||||
let params = if explicit { json!({"layer": layer, "path": target_path}) } else { json!({}) };
|
||||
let before = s.project.to_json();
|
||||
let selected = s.state.selected_keys.clone();
|
||||
let undo_len = s.history.undo.len();
|
||||
let result = s.execute("keys.paste", params.clone());
|
||||
assert!(result.is_err(), "path={path_clip}, explicit={explicit}: {result:?}");
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
assert_eq!(s.state.selected_keys, selected);
|
||||
assert_eq!(s.history.undo.len(), undo_len);
|
||||
s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap();
|
||||
let unlocked = s.project.to_json();
|
||||
s.execute("keys.paste", params).unwrap();
|
||||
let pasted = s.project.to_json();
|
||||
assert_ne!(pasted, unlocked);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), unlocked);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), pasted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_paste_skips_locked_targets_without_changing_layer_pairing() {
|
||||
let (mut s, locked, locked_prop) = setup();
|
||||
let other = s.execute("layer.newSolid", json!({"name": "Other", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
for (time, value) in [(0, 100), (1, 80), (2, 60)] {
|
||||
s.execute("prop.addKey", json!({"layer": other, "path": "transform/opacity", "time": time, "value": value})).unwrap();
|
||||
}
|
||||
let other_prop = s.active_comp().unwrap().layer(LayerId(other)).unwrap().props.prop("transform/opacity").unwrap().uid;
|
||||
let locked_keys = keys_of(&s, locked, locked_prop);
|
||||
s.state.selected_keys = locked_keys.iter().chain(keys_of(&s, other, other_prop).iter()).copied().collect();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": true})).unwrap();
|
||||
s.execute("time.set", json!({"time": 3})).unwrap();
|
||||
let before = s.project.to_json();
|
||||
assert_eq!(s.execute("keys.paste", json!({"layers": [locked, other]})).unwrap(), json!(3), "only newly pasted keys are counted");
|
||||
assert_eq!(keys_of(&s, locked, locked_prop), locked_keys);
|
||||
assert!(locked_keys.iter().all(|k| s.state.selected_keys.contains(k)));
|
||||
assert_eq!(s.state.selected_keys.len(), 6);
|
||||
let keys = &s.active_comp().unwrap().layer(LayerId(other)).unwrap().props.find(other_prop).unwrap().keys;
|
||||
assert_eq!(keys.iter().filter(|k| k.time.seconds() >= 3.0).map(|k| k.value.as_f64()).collect::<Vec<_>>(), [100.0, 80.0, 60.0]);
|
||||
let pasted = s.project.to_json();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), pasted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_path_paste_preserves_locked_source_selection() {
|
||||
for explicit in [false, true] {
|
||||
let (mut s, source, _) = setup();
|
||||
s.execute("mask.new", json!({"layer": source, "vertices": [[0, 0], [20, 10], [40, 0]], "closed": false})).unwrap();
|
||||
s.execute("prop.toggleAnimation", json!({"layer": source, "path": "masks/#1/path"})).unwrap();
|
||||
let uid = s.active_comp().unwrap().layer(LayerId(source)).unwrap().props.prop("masks/#1/path").unwrap().uid;
|
||||
s.execute("keys.select", json!({"keys": [{"layer": source, "prop": uid, "time": 0}]})).unwrap();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
let locked_keys = s.state.selected_keys.clone();
|
||||
let target = s.execute("layer.newSolid", json!({"name": "Target", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.select", json!({"layer": target, "path": "transform/position"})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [source], "switch": "locked", "value": true})).unwrap();
|
||||
s.state.selected_keys = locked_keys.clone();
|
||||
let params = if explicit { json!({"layer": target, "path": "transform/position"}) } else { json!({}) };
|
||||
let before = s.project.to_json();
|
||||
assert_eq!(s.execute("keys.paste", params).unwrap(), json!(3));
|
||||
assert!(locked_keys.iter().all(|k| s.state.selected_keys.contains(k)));
|
||||
assert_eq!(s.state.selected_keys.len(), 4);
|
||||
assert_eq!(s.state.selected_keys.iter().filter(|k| k.layer == LayerId(target)).count(), 3);
|
||||
let pasted = s.project.to_json();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), pasted);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_transform_preserves_locked_keys_in_a_mixed_selection() {
|
||||
let (mut s, locked, locked_prop) = setup();
|
||||
let unlocked = s.execute("layer.newSolid", json!({"name": "Unlocked", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
for t in 0..3 {
|
||||
s.execute("prop.addKey", json!({"layer": unlocked, "path": "transform/opacity", "time": t, "value": t * 25})).unwrap();
|
||||
}
|
||||
let unlocked_prop = s.active_comp().unwrap().layer(LayerId(unlocked)).unwrap().props.prop("transform/opacity").unwrap().uid;
|
||||
let locked_keys = keys_of(&s, locked, locked_prop);
|
||||
s.state.selected_keys = locked_keys.iter().chain(keys_of(&s, unlocked, unlocked_prop).iter()).copied().collect();
|
||||
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": true})).unwrap();
|
||||
let stored = |s: &Session, layer, prop| s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys.clone();
|
||||
let before = stored(&s, locked, locked_prop);
|
||||
let undo = s.history.undo.len();
|
||||
for offset in [0.5, 1.0] {
|
||||
s.execute("keys.transform", json!({"timeOffset": offset, "valueOffset": offset * 10.0, "merge": "mixed-transform", "fromStart": true})).unwrap();
|
||||
assert_eq!(stored(&s, locked, locked_prop), before);
|
||||
assert_eq!(s.state.selected_keys.len(), 6);
|
||||
assert!(locked_keys.iter().all(|k| s.state.selected_keys.contains(k)), "locked selections stay at their original times");
|
||||
assert!(keys_of(&s, unlocked, unlocked_prop).iter().all(|k| s.state.selected_keys.contains(k)), "unlocked selections follow the keys");
|
||||
let moved = stored(&s, unlocked, unlocked_prop);
|
||||
for (i, key) in moved.iter().enumerate() {
|
||||
assert_eq!(key.time.seconds(), i as f64 + offset);
|
||||
assert_eq!(key.value.as_f64(), i as f64 * 25.0 + offset * 10.0);
|
||||
}
|
||||
}
|
||||
assert_eq!(s.history.undo.len(), undo + 1, "one merged drag");
|
||||
let after = stored(&s, unlocked, unlocked_prop);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(stored(&s, locked, locked_prop), before);
|
||||
assert_eq!(stored(&s, unlocked, unlocked_prop), before);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(stored(&s, locked, locked_prop), before);
|
||||
assert_eq!(stored(&s, unlocked, unlocked_prop), after);
|
||||
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": false})).unwrap();
|
||||
s.execute("keys.transform", json!({"timeOffset": 0.5, "valueOffset": 10})).unwrap();
|
||||
assert_ne!(stored(&s, locked, locked_prop), before, "unlocking makes the keys editable again");
|
||||
}
|
||||
|
||||
@@ -184,6 +184,51 @@ fn mask_interpolation_creates_matching_keys() {
|
||||
assert!(s.execute_checked("mask.interpolate", json!({"matchingMethod": "magic", "times": [0, 0.8], "layer": clip.0})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_interpolation_skips_locked_selection_and_rejects_explicit_locked_targets() {
|
||||
let (mut s, locked, _) = setup(patch_frames(rotating_pose));
|
||||
let unlocked = effectcraft_project::LayerId(s.execute("layer.newSolid", json!({"width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap());
|
||||
let a = ShapePath::rect([20.0, 20.0], 10.0, 10.0);
|
||||
let b = ShapePath::rect([40.0, 30.0], 20.0, 20.0);
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let mut selected = vec![];
|
||||
let mut masks = vec![];
|
||||
for layer in [locked, unlocked] {
|
||||
let uid = new_mask(&mut s, layer, &a);
|
||||
masks.push(uid);
|
||||
s.edit("keys", None, |project, _| {
|
||||
let pr = project.comp_mut(cid).unwrap().layer_mut(layer).unwrap().props.find_group_mut(uid).unwrap().get_mut("path").unwrap();
|
||||
pr.keys = vec![Keyframe::new(frame_time(0), KV::Path(a.clone())), Keyframe::new(frame_time(20), KV::Path(b.clone()))];
|
||||
selected.extend(pr.keys.iter().map(|key| crate::KeyRef { layer, prop: pr.uid, time: key.time }));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
s.execute("layer.setSwitch", json!({"layers": [locked.0], "switch": "locked", "value": true})).unwrap();
|
||||
s.state.selected_keys = selected.clone();
|
||||
let before = mask_path(&s, locked, masks[0]).keys;
|
||||
let options = serde_json::to_value(&s.state.mask_interp).unwrap();
|
||||
let project = serde_json::to_value(&*s.project).unwrap();
|
||||
let error = s.execute_checked("mask.interpolate", json!({"layer": locked.0, "mask": masks[0], "times": [0.0, 0.8], "quality": 75})).unwrap_err();
|
||||
assert!(error.to_string().contains("locked"), "{error}");
|
||||
assert_eq!(serde_json::to_value(&*s.project).unwrap(), project);
|
||||
assert_eq!(serde_json::to_value(&s.state.mask_interp).unwrap(), options);
|
||||
assert_eq!(s.state.selected_keys, selected);
|
||||
s.execute_checked("mask.interpolate", json!({})).unwrap();
|
||||
assert_eq!(mask_path(&s, locked, masks[0]).keys, before);
|
||||
assert_eq!(mask_path(&s, unlocked, masks[1]).keys.len(), 21);
|
||||
assert_eq!(s.state.selected_keys, selected);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(mask_path(&s, unlocked, masks[1]).keys.len(), 2);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(mask_path(&s, unlocked, masks[1]).keys.len(), 21);
|
||||
s.state.selected_keys.retain(|key| key.layer == locked);
|
||||
let selection = s.state.selected_keys.clone();
|
||||
assert!(s.execute_checked("mask.interpolate", json!({})).is_err());
|
||||
assert_eq!(mask_path(&s, locked, masks[0]).keys, before);
|
||||
assert_eq!(s.state.selected_keys, selection);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Warp Stabilizer
|
||||
|
||||
/// Camera offset on frame `f`: a slow pan plus jitter.
|
||||
|
||||
@@ -298,6 +298,25 @@ fn interpolation_dialog_temporal_spatial_and_roving() {
|
||||
assert_ne!(prop(&s, l, "transform/position").keys[0].out_interp, Interp::Hold);
|
||||
}
|
||||
|
||||
/// Editing another key's value or time keeps the roving key at its share of the path (#1284: the
|
||||
/// roving key went back to where it was after edits to other keys).
|
||||
#[test]
|
||||
fn roving_key_follows_value_and_time_edits_to_other_keys() {
|
||||
let (mut s, l) = setup();
|
||||
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0])), (0.3, json!([300, 0])), (2.0, json!([400, 0]))]);
|
||||
let uid = prop(&s, l, "transform/position").uid;
|
||||
s.execute("keys.select", json!({"keys": [{"layer": l, "prop": uid, "time": 0.3}]})).unwrap();
|
||||
s.execute("keys.interpolation", json!({"roving": true})).unwrap();
|
||||
let roving_at = |s: &Session| prop(s, l, "transform/position").keys[1].time.seconds();
|
||||
assert!((roving_at(&s) - 1.5).abs() < 0.01, "start: {}", roving_at(&s));
|
||||
// The last key's value moves to 800 px: the path is 300 + 500 px, so the roving key is at 300 / 800.
|
||||
s.execute("keys.set", json!({"layer": l, "path": "transform/position", "time": 2.0, "value": [800, 0]})).unwrap();
|
||||
assert!((roving_at(&s) - 0.75).abs() < 0.02, "value edit: {}", roving_at(&s));
|
||||
// The last key moves to 3 s: the same 300 / 800 share of 3 s.
|
||||
s.execute("keys.set", json!({"layer": l, "path": "transform/position", "time": 2.0, "newTime": 3.0})).unwrap();
|
||||
assert!((roving_at(&s) - 1.125).abs() < 0.02, "time edit: {}", roving_at(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_editor_key_and_handle_edits() {
|
||||
let (mut s, l) = setup();
|
||||
@@ -325,6 +344,34 @@ fn graph_editor_key_and_handle_edits() {
|
||||
assert_eq!(prop(&s, l, "transform/opacity").keys[0].out_interp, Interp::Linear);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_editor_ease_edits_reject_a_locked_layer() {
|
||||
let (mut s, l) = setup();
|
||||
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (2.0, json!(0))]);
|
||||
select_prop_keys(&mut s, l, "transform/opacity");
|
||||
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "locked", "value": true})).unwrap();
|
||||
let before = prop(&s, l, "transform/opacity");
|
||||
let selected = s.state.selected_keys.clone();
|
||||
let undo = s.history.undo.len();
|
||||
for side in ["in", "out"] {
|
||||
let error =
|
||||
s.execute("keys.setEase", json!({"layer": l, "path": "transform/opacity", "time": 1.0, "side": side, "speed": 200, "influence": 60})).unwrap_err();
|
||||
assert!(error.to_string().contains("locked"), "{error}");
|
||||
assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys);
|
||||
assert_eq!(s.state.selected_keys, selected);
|
||||
assert_eq!(s.history.undo.len(), undo);
|
||||
}
|
||||
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "locked", "value": false})).unwrap();
|
||||
s.execute("keys.setEase", json!({"layer": l, "path": "transform/opacity", "time": 1.0, "side": "out", "speed": 200, "influence": 60})).unwrap();
|
||||
let after = prop(&s, l, "transform/opacity");
|
||||
assert_eq!(after.keys[1].out_interp, Interp::Bezier);
|
||||
assert_eq!(after.keys[1].out_ease[0].speed, 200.0);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(prop(&s, l, "transform/opacity").keys, after.keys);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_expression_to_keyframes_on_a_reversed_layer_keeps_values() {
|
||||
let (mut s, l) = setup();
|
||||
|
||||
@@ -90,7 +90,7 @@ impl EffectcraftApp {
|
||||
|
||||
/// Apply the engine events drained this tick: open a comp, show a toast, open a URL, run a frontend command,
|
||||
/// purge frame caches or copy an image; command and clipboard failures go to `app.ui.status`.
|
||||
pub(super) fn handle_events(&mut self, ctx: &egui::Context) {
|
||||
pub(crate) fn handle_events(&mut self, ctx: &egui::Context) {
|
||||
for ev in self.session.drain_events() {
|
||||
match ev {
|
||||
effectcraft_engine::Event::OpenComp(c) => {
|
||||
|
||||
@@ -406,7 +406,7 @@ impl EffectcraftApp {
|
||||
|
||||
/// A press in a panel (its body, tab or group header) focuses it; another Composition viewer
|
||||
/// also becomes the active one.
|
||||
fn focus_panel(&mut self, ctx: &egui::Context, p: PanelKind) {
|
||||
pub(crate) fn focus_panel(&mut self, ctx: &egui::Context, p: PanelKind) {
|
||||
self.ui.focused = p;
|
||||
panels::viewer_text::panel_clicked(self, ctx, p);
|
||||
if let Some(id) = panels::viewers::id_of(p) {
|
||||
|
||||
@@ -125,6 +125,9 @@ pub(crate) fn entry_enabled(app: &EffectcraftApp, e: &MenuEntry) -> bool {
|
||||
if e.command == "edit.clear" && app.ui.focused == PanelKind::RenderQueue {
|
||||
return !app.session.is_rendering() && !app.session.project.render_queue.is_empty();
|
||||
}
|
||||
if e.command == "file.close" && super::invoke::no_params(&e.params) && app.ui.focused == PanelKind::RenderQueue {
|
||||
return !app.ui.dock.locked;
|
||||
}
|
||||
if e.command == "window.panel" {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ fn vertex_delta(app: &EffectcraftApp, layer: effectcraft_engine::project::LayerI
|
||||
})
|
||||
}
|
||||
|
||||
fn no_params(p: &Value) -> bool {
|
||||
pub(super) fn no_params(p: &Value) -> bool {
|
||||
p.as_object().is_none_or(|m| m.is_empty())
|
||||
}
|
||||
|
||||
@@ -223,6 +223,21 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Close follows panel focus too: with the Render Queue active, Cmd+W closes that panel and
|
||||
// leaves the active composition open (it used to close the composition next to it).
|
||||
if id == "file.close" && no_params(¶ms) && app.ui.focused == PanelKind::RenderQueue {
|
||||
app.apply_dock(craft_ui::docking::Action::Close { panel: PanelKind::RenderQueue })?;
|
||||
// The focus goes to a panel that is still there, so the next Close acts on the composition
|
||||
// instead of on the queue that is gone.
|
||||
let next = [PanelKind::Timeline, PanelKind::Composition, PanelKind::Project].into_iter().find(|p| panel_shown(app, *p)).unwrap_or(PanelKind::Project);
|
||||
app.show_panel(next);
|
||||
app.focus_panel(ctx, next);
|
||||
// Activating a passive viewer emits OpenComp, which raises Timeline. Finish that routing
|
||||
// before restoring the destination panel, including when they share the same tab group.
|
||||
app.handle_events(ctx);
|
||||
app.show_panel(next);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
// Edit ▸ Paste with SVG markup on the system clipboard pastes it as a shape layer (After
|
||||
// Effects 26.3). Copying in the app replaces the system clipboard with a note naming what
|
||||
// was copied, so SVG there was copied after anything copied in the app.
|
||||
|
||||
@@ -418,6 +418,175 @@ fn tga_is_available_in_import_replace_and_proxy_dialogs() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cmd+W with the Render Queue focused closes the queue; the composition next to it stays
|
||||
/// open (it used to be closed, and the queue stayed).
|
||||
#[test]
|
||||
fn cmd_w_in_the_render_queue_closes_the_queue_not_the_comp() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
let a = app.session.execute("comp.new", json!({"name": "A", "duration": 4})).unwrap()["comp"].as_u64();
|
||||
app.show_panel(PanelKind::RenderQueue);
|
||||
assert_eq!(app.ui.focused, PanelKind::RenderQueue);
|
||||
invoke(&mut app, &ctx, "file.close", json!({})).unwrap();
|
||||
assert!(!app.ui.dock.is_visible(PanelKind::RenderQueue));
|
||||
assert_eq!(app.session.state.active_comp.map(|c| c.0), a);
|
||||
assert_eq!(app.session.state.open_comps.len(), 1);
|
||||
// The focus left the closed queue: the next Close closes the composition.
|
||||
assert_ne!(app.ui.focused, PanelKind::RenderQueue);
|
||||
invoke(&mut app, &ctx, "file.close", json!({})).unwrap();
|
||||
assert_eq!(app.session.state.open_comps.len(), 0, "a second Close acts on the composition");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_close_is_available_for_an_empty_render_queue_without_a_comp() {
|
||||
for params in [Value::Null, json!({})] {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
assert!(app.session.active_comp().is_none());
|
||||
app.show_panel(PanelKind::RenderQueue);
|
||||
assert!(menu_items(&app).iter().find(|item| item.id == "file.close").unwrap().enabled);
|
||||
invoke(&mut app, &ctx, "file.close", params).unwrap();
|
||||
assert!(!app.ui.dock.is_open(PanelKind::RenderQueue));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_the_render_queue_reveals_the_panel_receiving_focus() {
|
||||
for maximized in [false, true] {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
app.close_panel(PanelKind::Timeline);
|
||||
app.close_panel(PanelKind::Composition);
|
||||
app.show_panel(PanelKind::EffectControls);
|
||||
assert!(!app.ui.dock.is_visible(PanelKind::Project));
|
||||
app.show_panel(PanelKind::RenderQueue);
|
||||
if maximized {
|
||||
app.apply_dock(craft_ui::docking::Action::Float { panel: PanelKind::RenderQueue, rect: [50.0, 60.0, 400.0, 200.0] }).unwrap();
|
||||
app.toggle_maximize(PanelKind::EffectControls);
|
||||
app.ui.focused = PanelKind::RenderQueue;
|
||||
assert!(app.ui.dock.is_floating(PanelKind::RenderQueue));
|
||||
}
|
||||
invoke(&mut app, &ctx, "file.close", json!({})).unwrap();
|
||||
assert_eq!(app.ui.focused, PanelKind::Project);
|
||||
assert!(app.ui.dock.is_visible(app.ui.focused), "focus must belong to a front tab");
|
||||
assert_eq!(app.ui.maximized, None, "the focused docked panel must be on screen");
|
||||
}
|
||||
}
|
||||
|
||||
/// Closing a floating queue must focus the viewer that is actually shown, retaining maximization.
|
||||
#[test]
|
||||
fn closing_a_floating_queue_preserves_the_maximized_composition() {
|
||||
let mut session = effectcraft_engine::Session::default();
|
||||
session.execute("comp.new", json!({"name": "A", "width": 32, "height": 32, "duration": 2})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_| EffectcraftApp::new(session));
|
||||
h.run_steps(3);
|
||||
let ctx = h.ctx.clone();
|
||||
let app = h.state_mut();
|
||||
app.show_panel(PanelKind::Composition);
|
||||
app.show_panel(PanelKind::RenderQueue);
|
||||
app.apply_dock(craft_ui::docking::Action::Float { panel: PanelKind::RenderQueue, rect: [50.0, 60.0, 400.0, 200.0] }).unwrap();
|
||||
app.toggle_maximize(PanelKind::Composition);
|
||||
app.ui.focused = PanelKind::RenderQueue;
|
||||
assert!(app.ui.dock.is_visible(PanelKind::Timeline), "the hidden underlying tab triggers the regression");
|
||||
assert!(!panel_shown(app, PanelKind::Timeline));
|
||||
invoke(app, &ctx, "file.close", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().ui.focused, PanelKind::Composition);
|
||||
assert_eq!(h.state().ui.maximized, Some(PanelKind::Composition));
|
||||
assert!(!h.state().ui.dock.is_open(PanelKind::RenderQueue));
|
||||
assert_eq!(h.state().session.state.open_comps.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_close_preserves_state_when_workspace_is_locked() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
app.session.execute("comp.new", json!({"name": "A", "duration": 2})).unwrap();
|
||||
app.show_panel(PanelKind::RenderQueue);
|
||||
app.ui.dock.locked = true;
|
||||
let workspace = app.ui.dock.clone();
|
||||
let comp = app.session.active_comp_id();
|
||||
for _ in 0..2 {
|
||||
assert!(invoke(&mut app, &ctx, "file.close", Value::Null).is_err());
|
||||
assert_eq!(app.ui.dock, workspace);
|
||||
assert_eq!(app.ui.focused, PanelKind::RenderQueue);
|
||||
assert_eq!(app.session.active_comp_id(), comp);
|
||||
}
|
||||
assert!(!menu_items(&app).iter().find(|item| item.id == "file.close").unwrap().enabled);
|
||||
app.ui.dock.locked = false;
|
||||
invoke(&mut app, &ctx, "file.close", Value::Null).unwrap();
|
||||
assert!(!app.ui.dock.is_open(PanelKind::RenderQueue));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_close_preserves_viewer_command_target() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
let a = app.session.execute("comp.new", json!({"name": "A", "duration": 2})).unwrap()["comp"].as_u64().unwrap();
|
||||
let b = app.session.execute("comp.new", json!({"name": "B", "duration": 2})).unwrap()["comp"].as_u64().unwrap();
|
||||
app.show_panel(PanelKind::Viewer(1));
|
||||
app.ui.viewers.insert(0, Some(a));
|
||||
app.ui.viewers.insert(1, Some(b));
|
||||
app.ui.active_viewer = 1;
|
||||
app.apply_dock(craft_ui::docking::Action::Move {
|
||||
panel: PanelKind::Timeline,
|
||||
anchor: PanelKind::Composition,
|
||||
placement: craft_ui::docking::Placement::Tab { before: None },
|
||||
})
|
||||
.unwrap();
|
||||
app.close_panel(PanelKind::Timeline);
|
||||
app.show_panel(PanelKind::Composition);
|
||||
app.show_panel(PanelKind::RenderQueue);
|
||||
assert_eq!(app.session.active_comp_id().map(|id| id.0), Some(b));
|
||||
invoke(&mut app, &ctx, "file.close", json!({})).unwrap();
|
||||
assert_eq!(app.ui.focused, PanelKind::Composition);
|
||||
assert_eq!(app.ui.active_viewer, 0);
|
||||
assert_eq!(app.session.active_comp_id().map(|id| id.0), Some(a));
|
||||
invoke(&mut app, &ctx, "file.close", json!({})).unwrap();
|
||||
assert!(!app.session.state.open_comps.iter().any(|id| id.0 == a));
|
||||
assert!(app.session.state.open_comps.iter().any(|id| id.0 == b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_close_keeps_passive_viewer_focus_across_frames() {
|
||||
let mut session = effectcraft_engine::Session::default();
|
||||
let a = session.execute("comp.new", json!({"name": "A", "width": 32, "height": 32, "duration": 2})).unwrap()["comp"].as_u64().unwrap();
|
||||
let b = session.execute("comp.new", json!({"name": "B", "width": 32, "height": 32, "duration": 2})).unwrap()["comp"].as_u64().unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_| EffectcraftApp::new(session));
|
||||
h.run_steps(3);
|
||||
let ctx = h.ctx.clone();
|
||||
let app = h.state_mut();
|
||||
app.show_panel(PanelKind::Viewer(1));
|
||||
app.ui.viewers.insert(0, Some(a));
|
||||
app.ui.viewers.insert(1, Some(b));
|
||||
app.ui.active_viewer = 1;
|
||||
app.apply_dock(craft_ui::docking::Action::Move {
|
||||
panel: PanelKind::Timeline,
|
||||
anchor: PanelKind::Composition,
|
||||
placement: craft_ui::docking::Placement::Tab { before: None },
|
||||
})
|
||||
.unwrap();
|
||||
app.close_panel(PanelKind::Timeline);
|
||||
app.show_panel(PanelKind::Composition);
|
||||
app.show_panel(PanelKind::RenderQueue);
|
||||
invoke(app, &ctx, "file.close", Value::Null).unwrap();
|
||||
h.run_steps(4);
|
||||
let app = h.state_mut();
|
||||
assert_eq!(app.ui.focused, PanelKind::Composition);
|
||||
assert_eq!(app.ui.active_viewer, 0);
|
||||
assert_eq!(app.session.active_comp_id().map(|id| id.0), Some(a));
|
||||
assert!(app.ui.dock.is_visible(PanelKind::Composition));
|
||||
assert!(!app.ui.dock.is_open(PanelKind::RenderQueue));
|
||||
invoke(app, &ctx, "file.close", json!({})).unwrap();
|
||||
assert!(!app.session.state.open_comps.iter().any(|id| id.0 == a));
|
||||
assert!(app.session.state.open_comps.iter().any(|id| id.0 == b));
|
||||
// A later explicit Open still routes normally, rather than inheriting a deferred focus override.
|
||||
invoke(app, &ctx, "comp.open", json!({"comp": b})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().ui.dock.is_visible(PanelKind::Timeline));
|
||||
assert_eq!(h.state().session.active_comp_id().map(|id| id.0), Some(b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_items_cover_the_tree() {
|
||||
assert_eq!(menus().last(), Some(&"Help"));
|
||||
|
||||
@@ -470,6 +470,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
let mut key_screens: Vec<(serde_json::Value, Pos2)> = vec![];
|
||||
for c in &cs {
|
||||
let (lid, uid, d) = (c.layer.id, c.prop.uid, c.dim);
|
||||
let unlocked = !c.layer.switches.locked;
|
||||
for k in &c.keys {
|
||||
let kp = pos2(tm.x(k.t), ymap(k.v));
|
||||
// In the speed graph an end key rises with the handle being dragged; skipping it once it
|
||||
@@ -491,9 +492,10 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
app.auto.add(&format!("timeline.graph.handle.{uid}.{d}.{}.{side}", k.idx), hr, "Bezier handle");
|
||||
// The same side's handles of every selected key move with it, by as much as the
|
||||
// pointer moved (After Effects), in one undo step.
|
||||
if hresp.drag_started() {
|
||||
if unlocked && hresp.drag_started() {
|
||||
let grabs: Vec<HandleGrab> = cs
|
||||
.iter()
|
||||
.filter(|c2| !c2.layer.switches.locked)
|
||||
.flat_map(|c2| {
|
||||
c2.keys.iter().flat_map(move |k2| {
|
||||
k2.handles.iter().filter(|h2| h2.out == h.out).map(move |h2| HandleGrab {
|
||||
@@ -513,7 +515,8 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
let press = ui.input(|i| i.pointer.press_origin()).unwrap_or(hp);
|
||||
ctx.data_mut(|dd| dd.insert_temp::<HandleDrag>(handle_drag_id(), (press, grabs)));
|
||||
}
|
||||
if hresp.dragged()
|
||||
if unlocked
|
||||
&& hresp.dragged()
|
||||
&& let Some(pt) = hresp.interact_pointer_pos()
|
||||
&& let Some((press, grabs)) = ctx.data(|dd| dd.get_temp::<HandleDrag>(handle_drag_id()))
|
||||
{
|
||||
@@ -544,7 +547,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
app.auto.add(&format!("timeline.graph.key.{uid}.{d}.{}", k.idx), kr, &c.prop.name);
|
||||
let mods = ui.input(|i| i.modifiers);
|
||||
let key = &c.prop.keys[k.idx];
|
||||
if kresp.clicked() && mods.command {
|
||||
if unlocked && kresp.clicked() && mods.command {
|
||||
// Ctrl+click: Linear ↔ Auto Bezier; Ctrl+Alt+click: Hold on / off (as in the Timeline).
|
||||
actions.push(("keys.select".into(), json!({"keys": [kjson.clone()]})));
|
||||
if mods.alt {
|
||||
@@ -553,20 +556,22 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
let linear = key.in_interp == Interp::Linear && key.out_interp == Interp::Linear;
|
||||
actions.push(("keys.interpolation".into(), json!({"interpolation": if linear { "autoBezier" } else { "linear" }})));
|
||||
}
|
||||
} else if kresp.clicked() && mods.shift {
|
||||
} else if unlocked && kresp.clicked() && mods.shift {
|
||||
actions.push(("keys.select".into(), json!({"keys": [kjson.clone()], "toggle": true})));
|
||||
} else if (kresp.clicked() || kresp.drag_started()) && !k.sel {
|
||||
} else if unlocked && (kresp.clicked() || kresp.drag_started() || kresp.secondary_clicked()) && !k.sel {
|
||||
actions.push(("keys.select".into(), json!({"keys": [kjson.clone()], "add": mods.shift})));
|
||||
}
|
||||
// A drag moves every selected key, by how far the pointer went from where the button
|
||||
// went down (the key doesn't jump under the pointer); Shift keeps it to the time or
|
||||
// the value axis, whichever moved more.
|
||||
if kresp.drag_started()
|
||||
if unlocked
|
||||
&& kresp.drag_started()
|
||||
&& let Some(o) = ui.input(|i| i.pointer.press_origin())
|
||||
{
|
||||
ctx.data_mut(|dd| dd.insert_temp(key_grab_id(), KeyGrab { press: o, t: k.t }));
|
||||
}
|
||||
if kresp.dragged()
|
||||
if unlocked
|
||||
&& kresp.dragged()
|
||||
&& let Some(pt) = kresp.interact_pointer_pos()
|
||||
&& let Some(g) = ctx.data(|dd| dd.get_temp::<KeyGrab>(key_grab_id()))
|
||||
{
|
||||
@@ -587,6 +592,8 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
ctx.data_mut(|dd| dd.insert_temp(drag_id, false));
|
||||
actions.push(("__endMerge".into(), json!({})));
|
||||
}
|
||||
// Right-click: the Timeline's key menu, with the same commands.
|
||||
crate::widgets::context_menu(&kresp, |ui| super::timeline::key_menu(ui, app, c.layer, c.prop, key, k.sel, actions));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4100,72 +4100,91 @@ fn key_double_click(g: &mut Rw, rc: &RowCtx<'_>, prop: &Property, kh: &KeyHit<'_
|
||||
/// The keyframe right-click menu.
|
||||
fn key_context_menu(app: &mut EffectcraftApp, g: &mut Rw, rc: &RowCtx<'_>, prop: &Property, kh: &KeyHit<'_>) {
|
||||
let KeyHit { k, ks, ref kresp, .. } = *kh;
|
||||
let t = g.tl.t;
|
||||
let actions = &mut g.tl.actions;
|
||||
let layer = rc.layer;
|
||||
let uid = &prop.uid;
|
||||
crate::widgets::context_menu(kresp, |ui| key_menu(ui, app, layer, prop, k, ks, actions));
|
||||
}
|
||||
|
||||
crate::widgets::context_menu(kresp, |ui| {
|
||||
for (lbl, cmd, params) in [
|
||||
("Edit Value…", "keys.set", json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()})),
|
||||
("-", "", json!({})),
|
||||
("Copy", "keys.copy", json!({})),
|
||||
("Paste", "keys.paste", json!({})),
|
||||
("-", "", json!({})),
|
||||
("Select Equal Keyframes", "keys.selectEqual", json!({})),
|
||||
("Select Previous Keyframes", "keys.selectPrevious", json!({})),
|
||||
("Select Following Keyframes", "keys.selectFollowing", json!({})),
|
||||
("-", "", json!({})),
|
||||
("Keyframe Interpolation…", "keys.interpolation", json!({})),
|
||||
("Keyframe Velocity…", "keys.velocity", json!({})),
|
||||
("Toggle Hold Keyframe", "keys.toggleHold", json!({})),
|
||||
(if k.muted { "Unmute Keyframes" } else { "Mute Keyframes" }, "keys.setMuted", json!({"muted": !k.muted})),
|
||||
("Rove Across Time", "keys.interpolation", json!({"roving": !k.roving})),
|
||||
("-", "", json!({})),
|
||||
("Easy Ease", "keys.easyEase", json!({})),
|
||||
("Easy Ease In", "keys.easyEaseIn", json!({})),
|
||||
("Easy Ease Out", "keys.easyEaseOut", json!({})),
|
||||
("Time-Reverse Keyframes", "keys.timeReverse", json!({})),
|
||||
("-", "", json!({})),
|
||||
("Linear", "keys.interpolation", json!({"interpolation": "linear"})),
|
||||
("Bezier", "keys.interpolation", json!({"interpolation": "bezier"})),
|
||||
("Auto Bezier", "keys.interpolation", json!({"interpolation": "autoBezier"})),
|
||||
("Select All Keyframes", "keys.selectAll", json!({})),
|
||||
("Delete", "keys.delete", json!({})),
|
||||
] {
|
||||
if lbl == "Select All Keyframes" {
|
||||
// Keyframe colour labels.
|
||||
/// The commands of a keyframe's right-click menu, for the Timeline and the Graph Editor alike. They act
|
||||
/// on the selected keys, so a menu opened on an unselected key selects it first. A locked layer's keys
|
||||
/// can still be copied and selected, but not edited.
|
||||
pub(crate) fn key_menu(
|
||||
ui: &mut egui::Ui,
|
||||
app: &EffectcraftApp,
|
||||
layer: &Layer,
|
||||
prop: &Property,
|
||||
k: &effectcraft_engine::keyframe::Keyframe,
|
||||
selected: bool,
|
||||
actions: &mut Vec<(String, serde_json::Value)>,
|
||||
) {
|
||||
let t = app.tokens;
|
||||
let locked = layer.switches.locked;
|
||||
let uid = &prop.uid;
|
||||
let this_key = json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()});
|
||||
for (lbl, cmd, params) in [
|
||||
("Edit Value…", "keys.set", this_key.clone()),
|
||||
("-", "", json!({})),
|
||||
("Copy", "keys.copy", json!({})),
|
||||
("Paste", "keys.paste", json!({})),
|
||||
("-", "", json!({})),
|
||||
("Select Equal Keyframes", "keys.selectEqual", json!({})),
|
||||
("Select Previous Keyframes", "keys.selectPrevious", json!({})),
|
||||
("Select Following Keyframes", "keys.selectFollowing", json!({})),
|
||||
("-", "", json!({})),
|
||||
("Keyframe Interpolation…", "keys.interpolation", json!({})),
|
||||
("Keyframe Velocity…", "keys.velocity", json!({})),
|
||||
("Toggle Hold Keyframe", "keys.toggleHold", json!({})),
|
||||
(if k.muted { "Unmute Keyframes" } else { "Mute Keyframes" }, "keys.setMuted", json!({"muted": !k.muted})),
|
||||
("Rove Across Time", "keys.interpolation", json!({"roving": !k.roving})),
|
||||
("-", "", json!({})),
|
||||
("Easy Ease", "keys.easyEase", json!({})),
|
||||
("Easy Ease In", "keys.easyEaseIn", json!({})),
|
||||
("Easy Ease Out", "keys.easyEaseOut", json!({})),
|
||||
("Time-Reverse Keyframes", "keys.timeReverse", json!({})),
|
||||
("-", "", json!({})),
|
||||
("Linear", "keys.interpolation", json!({"interpolation": "linear"})),
|
||||
("Bezier", "keys.interpolation", json!({"interpolation": "bezier"})),
|
||||
("Auto Bezier", "keys.interpolation", json!({"interpolation": "autoBezier"})),
|
||||
("Select All Keyframes", "keys.selectAll", json!({})),
|
||||
("Delete", "keys.delete", json!({})),
|
||||
] {
|
||||
if lbl == "Select All Keyframes" {
|
||||
// Keyframe colour labels.
|
||||
ui.add_enabled_ui(!locked, |ui| {
|
||||
ui.menu_button(crate::i18n::tr("Label"), |ui| {
|
||||
for (i, l) in effectcraft_engine::color::Label::ALL.into_iter().enumerate() {
|
||||
if widgets::label_entry(ui, l, &app.session.prefs.label_name(l), k.label as usize == i, &t).clicked() {
|
||||
if !ks {
|
||||
actions.push(("keys.select".into(), json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]})));
|
||||
if !selected {
|
||||
actions.push(("keys.select".into(), json!({"keys": [this_key.clone()]})));
|
||||
}
|
||||
actions.push(("keys.setLabel".into(), json!({"label": i})));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if lbl == "-" {
|
||||
ui.separator();
|
||||
continue;
|
||||
}
|
||||
if lbl == "Rove Across Time" && !prop.spatial {
|
||||
continue;
|
||||
}
|
||||
if lbl == "Edit Value…" && !matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
|
||||
continue;
|
||||
}
|
||||
if ui.button(lbl).clicked() {
|
||||
if !ks {
|
||||
actions.push(("keys.select".into(), json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]})));
|
||||
}
|
||||
actions.push((cmd.into(), params));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
if lbl == "-" {
|
||||
ui.separator();
|
||||
continue;
|
||||
}
|
||||
if lbl == "Rove Across Time" && !prop.spatial {
|
||||
continue;
|
||||
}
|
||||
if lbl == "Edit Value…" && !matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
|
||||
continue;
|
||||
}
|
||||
let edits = !matches!(cmd, "keys.copy" | "keys.selectEqual" | "keys.selectPrevious" | "keys.selectFollowing" | "keys.selectAll");
|
||||
// An unselected key is selected before the command runs, and locked keys cannot be selected.
|
||||
let unselectable = locked && !selected && cmd != "keys.selectAll";
|
||||
if ui.add_enabled(!(unselectable || locked && edits), egui::Button::new(crate::i18n::context_item(cmd, ¶ms, lbl))).clicked() {
|
||||
if !selected {
|
||||
actions.push(("keys.select".into(), json!({"keys": [this_key.clone()]})));
|
||||
}
|
||||
actions.push((cmd.into(), params));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a key drag (or Alt-scale).
|
||||
|
||||
@@ -540,6 +540,94 @@ fn disk_bar_range_covers_partial_frames() {
|
||||
let above_limit = TMap { start: 100_001.0, ..tm };
|
||||
assert_eq!(disk_bar_range(&above_limit, clip, 1.0, i64::MAX), 100_000..100_000);
|
||||
}
|
||||
|
||||
/// The keyframe menu is one for the Timeline and the Graph Editor: its commands are drawn in the
|
||||
/// interface language, and a locked layer's keys can be copied and selected but not edited.
|
||||
#[test]
|
||||
fn the_key_menu_is_translated_and_cannot_edit_a_locked_layers_keys() {
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
let mut s = effectcraft_engine::Session::default();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 64, "height": 64, "duration": 2})).unwrap();
|
||||
let id = s.execute("layer.newSolid", json!({"name": "Box", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
for (t, v) in [(0.0, 0), (1.0, 50)] {
|
||||
s.execute("prop.addKey", json!({"layer": id, "path": "transform/opacity", "time": t, "value": v})).unwrap();
|
||||
}
|
||||
let app = EffectcraftApp::new(s);
|
||||
let layer = app.session.active_comp().unwrap().layer(LayerId(id)).unwrap().clone();
|
||||
let prop = layer.props.prop("transform/opacity").unwrap().clone();
|
||||
let key = prop.keys[0].clone();
|
||||
let enabled = |locked: bool, selected: bool, label: &str| {
|
||||
let mut layer = layer.clone();
|
||||
layer.switches.locked = locked;
|
||||
let mut actions = vec![];
|
||||
let mut h = Harness::new_ui(|ui| key_menu(ui, &app, &layer, &prop, &key, selected, &mut actions));
|
||||
h.run();
|
||||
!h.get_by_label(label).accesskit_node().is_disabled()
|
||||
};
|
||||
for label in [
|
||||
"Copy",
|
||||
"Select Equal Keyframes",
|
||||
"Select All Keyframes",
|
||||
"Delete",
|
||||
"Edit Value…",
|
||||
"Easy Ease",
|
||||
"Time-Reverse Keyframes",
|
||||
"Keyframe Velocity…",
|
||||
"Mute Keyframes",
|
||||
] {
|
||||
assert!(enabled(false, true, label), "{label} on an unlocked layer");
|
||||
}
|
||||
for label in ["Copy", "Select Equal Keyframes", "Select All Keyframes"] {
|
||||
assert!(enabled(true, true, label), "{label} is still available on a locked layer");
|
||||
}
|
||||
for label in ["Delete", "Edit Value…", "Easy Ease", "Time-Reverse Keyframes", "Keyframe Velocity…", "Mute Keyframes", "Linear"] {
|
||||
assert!(!enabled(true, true, label), "{label} is off on a locked layer");
|
||||
assert!(!enabled(true, false, label), "{label} is off on an unselected locked key");
|
||||
}
|
||||
for label in ["Copy", "Select Equal Keyframes", "Select Previous Keyframes", "Select Following Keyframes"] {
|
||||
assert!(!enabled(true, false, label), "{label} requires selecting the clicked key");
|
||||
}
|
||||
assert!(enabled(true, false, "Select All Keyframes"));
|
||||
// The labels follow the interface language.
|
||||
crate::i18n::set_current("es");
|
||||
let copy = crate::i18n::context_item("keys.copy", &json!({}), "Copy");
|
||||
assert_ne!(copy, "Copy", "the catalog translates Copy");
|
||||
assert!(enabled(false, true, ©));
|
||||
crate::i18n::set_current("en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_menu_edit_selects_the_clicked_key_before_applying_the_command() {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
let mut s = effectcraft_engine::Session::default();
|
||||
s.execute("comp.new", json!({"width": 64, "height": 64, "duration": 2})).unwrap();
|
||||
let id = s.execute("layer.newSolid", json!({"color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
for (time, value) in [(0.0, 0), (1.0, 50)] {
|
||||
s.execute("prop.addKey", json!({"layer": id, "path": "transform/opacity", "time": time, "value": value})).unwrap();
|
||||
}
|
||||
let layer = s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone();
|
||||
let prop = layer.props.prop("transform/opacity").unwrap().clone();
|
||||
s.execute("keys.select", json!({"keys": [{"layer": id, "prop": prop.uid, "time": 1.0}]})).unwrap();
|
||||
let mut app = EffectcraftApp::new(s);
|
||||
let mut actions = vec![];
|
||||
{
|
||||
let mut h = Harness::new_ui(|ui| key_menu(ui, &app, &layer, &prop, &prop.keys[0], false, &mut actions));
|
||||
h.run();
|
||||
h.get_by_label("Easy Ease").click();
|
||||
h.run();
|
||||
}
|
||||
assert_eq!(actions.iter().map(|(command, _)| command.as_str()).collect::<Vec<_>>(), ["keys.select", "keys.easyEase"]);
|
||||
for (command, params) in actions {
|
||||
app.session.execute(&command, params).unwrap();
|
||||
}
|
||||
let keys = &app.session.active_comp().unwrap().layer(LayerId(id)).unwrap().props.find(prop.uid).unwrap().keys;
|
||||
assert_ne!(keys[0], prop.keys[0], "the clicked key was eased");
|
||||
assert_eq!(keys[1], prop.keys[1], "the previously selected key was not edited");
|
||||
assert_eq!(app.session.state.selected_keys.len(), 1);
|
||||
assert_eq!(app.session.state.selected_keys[0].time, prop.keys[0].time);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_drop_target_skips_moving_layers_beyond_the_viewport() {
|
||||
let mut rows: Vec<Row> = (1..=6).map(|id| Row { layer: LayerId(id), depth: 0, kind: RowKind::Layer }).collect();
|
||||
|
||||
@@ -77,3 +77,75 @@ fn open_graph_toggle_twice(h: &mut Harness<'_, EffectcraftApp>) {
|
||||
assert!(!h.state().ui.timeline.graph_editor);
|
||||
open_graph(h);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_graph_keys_preserve_the_existing_selection_and_keyframes() {
|
||||
use egui::{Event, Modifiers, PointerButton, pos2, vec2};
|
||||
for (button, modifiers, drag) in [
|
||||
(PointerButton::Primary, Modifiers::NONE, false),
|
||||
(PointerButton::Primary, Modifiers::SHIFT, false),
|
||||
(PointerButton::Primary, Modifiers::COMMAND, false),
|
||||
(PointerButton::Secondary, Modifiers::NONE, false),
|
||||
(PointerButton::Primary, Modifiers::NONE, true),
|
||||
] {
|
||||
let (mut h, layer, uid) = harness(true);
|
||||
let s = &mut h.state_mut().session;
|
||||
let locked = s.execute("layer.newSolid", json!({"name": "Locked", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
for (time, value) in [(9.0, 100.0), (11.0, 200.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": locked, "path": "transform/rotation", "time": time, "value": value})).unwrap();
|
||||
}
|
||||
let locked_id = effectcraft_engine::project::LayerId(locked);
|
||||
let locked_uid = s.active_comp().unwrap().layer(locked_id).unwrap().props.prop("transform/rotation").unwrap().uid;
|
||||
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": true})).unwrap();
|
||||
s.execute("keys.select", json!({"keys": [{"layer": layer, "prop": uid, "time": 12.0}]})).unwrap();
|
||||
s.state.selected_props = vec![(effectcraft_engine::project::LayerId(layer), uid), (locked_id, locked_uid)];
|
||||
let selection = s.state.selected_keys.clone();
|
||||
let before = s.project.clone();
|
||||
open_graph(&mut h);
|
||||
h.state_mut().ui.timeline.graph_auto_zoom = false;
|
||||
h.state_mut().ui.timeline.graph_range = Some((0.0, 800.0));
|
||||
h.run_steps(3);
|
||||
let [x, y, w, height] = h.state().auto.find(&format!("timeline.graph.key.{locked_uid}.0.0")).expect("locked key drawn").rect;
|
||||
let point = pos2(x + w / 2.0, y + height / 2.0);
|
||||
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
|
||||
h.input_mut().events.push(Event::PointerMoved(point));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: point, button, pressed: true, modifiers });
|
||||
h.step();
|
||||
let end = if drag { point + vec2(30.0, -20.0) } else { point };
|
||||
h.input_mut().events.push(Event::PointerMoved(end));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: end, button, pressed: false, modifiers });
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.state.selected_keys, selection, "{button:?}, {modifiers:?}, drag={drag}");
|
||||
assert_eq!(
|
||||
serde_json::to_value(&h.state().session.project).unwrap(),
|
||||
serde_json::to_value(&before).unwrap(),
|
||||
"locked gestures must not edit another key"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unlocked_graph_keys_menu_edits_the_clicked_key() {
|
||||
use egui::{Event, Modifiers, PointerButton, pos2};
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let (mut h, layer, uid) = harness(true);
|
||||
h.state_mut().session.execute("keys.select", json!({"keys": [{"layer": layer, "prop": uid, "time": 12.0}]})).unwrap();
|
||||
open_graph(&mut h);
|
||||
let [x, y, w, height] = h.state().auto.find(&format!("timeline.graph.key.{uid}.0.0")).unwrap().rect;
|
||||
let at = pos2(x + w / 2.0, y + height / 2.0);
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::PointerMoved(at));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
}
|
||||
h.run_steps(3);
|
||||
let selected = &h.state().session.state.selected_keys;
|
||||
assert_eq!(selected.len(), 1);
|
||||
assert_eq!(selected[0].time.seconds(), 8.0);
|
||||
h.get_by_label("Easy Ease").click();
|
||||
h.run_steps(3);
|
||||
let prop = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.find(uid).unwrap();
|
||||
assert_eq!(prop.keys[0].out_interp, effectcraft_engine::keyframe::Interp::Bezier);
|
||||
assert_eq!(prop.keys[1].out_interp, effectcraft_engine::keyframe::Interp::Linear);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user