Merge remote-tracking branch 'origin/main' into HEAD

This commit is contained in:
bflatastic committed 2026-10-30 11:47:04 +07:00
commit 9fb8340cfe
71 files changed
+2617 -277

No files matched your search

+6 -7
View File
@@ -4,7 +4,7 @@ use effectcraft_engine::Session;
use effectcraft_engine::time::Tick;
use serde_json::{Value, json};
use crate::confine::Confinement;
use crate::confine::{Confinement, Lookup};
use crate::{BridgeClient, Error, Result, base64, encode_png, recompress_png};
pub struct Backend {
@@ -63,12 +63,11 @@ impl Backend {
pub fn exec(&mut self, id: &str, params: Value) -> Result<Value> {
let params = if params.is_null() { json!({}) } else { params };
if let Some(c) = self.confinement.clone() {
let lookup = match c.check_command(id, &params)? {
Some(crate::confine::Lookup::ProjectFile) => Some((crate::confine::Lookup::ProjectFile, self.run("project.summary", json!({}))?)),
Some(crate::confine::Lookup::RenderOutputs) => Some((crate::confine::Lookup::RenderOutputs, self.run("renderQueue.list", json!({}))?)),
None => None,
};
if let Some((lookup, found)) = lookup {
for lookup in c.check_command(id, &params)? {
let found = match lookup {
Lookup::ProjectFile | Lookup::QueueDefault => self.run("project.summary", json!({}))?,
Lookup::RenderOutputs => self.run("renderQueue.list", json!({}))?,
};
c.check_lookup(&lookup, &found)?;
}
}
+39 -10
View File
@@ -6,10 +6,11 @@
//! `engine.batch`) is checked before it runs: its file parameters must be absolute paths inside
//! the roots (symbolic links resolved, no `..`), any other absolute path in its parameters too,
//! and commands that write to a file they are not given (Save, Increment and Save, rendering the
//! Render Queue) are checked against where they would write. Commands that run code or open
//! other applications (scripts, ScriptUI panels and the events that reach them, Execute File,
//! Reveal in Finder…) are refused while confined, as is granting scripts the file and network
//! access the user withheld.
//! Render Queue) are checked against where they would write. Queueing a render without naming
//! its output needs a saved project, because the default output of an unsaved one is the home
//! folder. Commands that run code or open other applications (scripts, ScriptUI panels and the
//! events that reach them, Execute File, Reveal in Finder…) are refused while confined, as is
//! granting scripts the file and network access the user withheld.
use std::path::{Component, Path, PathBuf};
@@ -121,6 +122,9 @@ const WRITES: &[(&str, &[&str])] = &[
/// Commands that write where they are not told: the project's file, the Render Queue's outputs.
const IMPLICIT_WRITES: &[&str] = &["file.save", "file.incrementAndSave", "renderQueue.render", "render.preRender"];
/// Commands that queue a render, whose output defaults to the project's folder when not given.
const QUEUES_AN_OUTPUT: &[&str] = &["renderQueue.add", "render.preRender", "file.createProxy"];
/// What must be looked up before an implicitly writing command may run.
#[derive(Debug, PartialEq, Eq)]
pub enum Lookup {
@@ -128,6 +132,10 @@ pub enum Lookup {
ProjectFile,
/// Every output of the queued Render Queue items (`renderQueue.list`).
RenderOutputs,
/// Whether the open project has a file (`project.summary` → `path`): a render queued without
/// `output` (Render Queue, Pre-render, Create Proxy) writes beside it, or to the home folder
/// before the first save.
QueueDefault,
}
impl Confinement {
@@ -166,7 +174,7 @@ impl Confinement {
/// Check engine command `id` with `params` before it runs. Returns what must still be looked
/// up and passed to [`Confinement::check_lookup`] for commands that write implicitly.
pub fn check_command(&self, id: &str, params: &Value) -> Result<Option<Lookup>> {
pub fn check_command(&self, id: &str, params: &Value) -> Result<Vec<Lookup>> {
if REFUSED.contains(&id) || effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id) {
return Err(Error::BadArgs(format!("`{id}` is not available while file access is confined (--automation-read-root/--automation-write-root)")));
}
@@ -189,11 +197,11 @@ impl Confinement {
if named.iter().chain(mixed).filter_map(|k| inner_params.get(*k)).any(has_reference) {
return Err(Error::BadArgs(format!("`{inner}` in a batch: file paths can't be `$N` references while file access is confined")));
}
if self.check_command(inner, &inner_params)?.is_some() {
if !self.check_command(inner, &inner_params)?.is_empty() {
return Err(Error::BadArgs(format!("`{inner}` writes to its project's or queue's files: run it on its own while file access is confined")));
}
}
return Ok(None);
return Ok(vec![]);
}
let (named, access) = match (keys_of(WRITES, id), keys_of(READS, id)) {
(Some(k), _) => (k, Access::Write),
@@ -202,6 +210,10 @@ impl Confinement {
(None, None) => (&[][..], Access::Read),
};
let mut given = false;
// Whether one of them is a file: not a Render Queue template, whose own output is relative
// and resolved like one the agent never gave, and not an array, which the commands that
// take a single output read as nothing at all.
let mut gave_a_file = false;
for key in named {
let Some(v) = params.get(*key).filter(|v| !v.is_null()) else { continue };
given = true;
@@ -214,6 +226,7 @@ impl Confinement {
match p.as_str() {
Some(s) if matches!(id, "render.preRender" | "file.createProxy") && !looks_like_path(s) => {}
Some(s) => {
gave_a_file |= v.is_string();
self.check(s, access)?;
}
None => return Err(Error::BadArgs(format!("`{id}`: `{key}` must be a path"))),
@@ -239,10 +252,19 @@ impl Confinement {
if matches!(id, "file.openRecent" | "file.importRecent" | "anim.applyRecentPreset") && !given {
return Err(Error::BadArgs(format!("`{id}`: give `path` while file access is confined")));
}
if IMPLICIT_WRITES.contains(&id) && !(given && id == "file.save") {
return Ok(Some(if id == "file.save" || id == "file.incrementAndSave" { Lookup::ProjectFile } else { Lookup::RenderOutputs }));
let mut lookups = vec![];
// An output the agent did not name is derived from where the project sits: the Default
// Output Folder preference when that is set, beside the project file otherwise — and the
// home folder when the project has never been saved, outside every root (#2479). Only
// that last case is certainly outside, and reading the preference here would duplicate
// the engine's precedence, so an unsaved project is told to name its output instead.
if QUEUES_AN_OUTPUT.contains(&id) && !gave_a_file {
lookups.push(Lookup::QueueDefault);
}
Ok(None)
if IMPLICIT_WRITES.contains(&id) && !(given && id == "file.save") {
lookups.push(if id == "file.save" || id == "file.incrementAndSave" { Lookup::ProjectFile } else { Lookup::RenderOutputs });
}
Ok(lookups)
}
/// Check what a [`Lookup`] found: `project.summary` or `renderQueue.list`'s result.
@@ -253,6 +275,13 @@ impl Confinement {
Some(p) => self.check(p, Access::Write).map(drop),
None => Err(Error::BadArgs("the project has no file yet: give `path` (Save As) inside an automation write root".into())),
},
// A saved project's default output sits beside it, and rendering checks it then.
Lookup::QueueDefault => match found.get("path").and_then(Value::as_str) {
Some(_) => Ok(()),
None => Err(Error::BadArgs(
"the project has no file yet, so the default output would go to the home folder: give `output` inside an automation write root, or save the project inside one first".into(),
)),
},
Lookup::RenderOutputs => {
let items = found.get("items").or(Some(found)).and_then(Value::as_array).cloned().unwrap_or_default();
for it in items.iter().filter(|it| it.get("render").and_then(Value::as_bool) != Some(false)) {
+60 -1
View File
@@ -1416,6 +1416,12 @@ fn automation_roots_confine_file_access() {
assert!(err, "{name} {args} should be refused: {c:?}");
c[0]["text"].as_str().unwrap_or_default().to_string()
};
// #2479: an unsaved project's queue item without `output` would write to the home folder.
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "Unsaved", "width": 64, "height": 64}}));
let msg = denied(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "Unsaved"}}));
assert!(msg.contains("home folder") && msg.contains("give `output`"), "{msg}");
call_json(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "Unsaved", "output": p(&write, "unsaved.mp4")}}));
call_json(&mut s, "execute_command", json!({"command": "renderQueue.remove"}));
// Reading: inside a read root, not outside.
call_json(&mut s, "open_project", json!({"path": p(&read, "source.ecproj")}));
let msg = denied(&mut s, "open_project", json!({"path": p(&outside, "free.ecproj")}));
@@ -1473,6 +1479,59 @@ fn automation_roots_confine_file_access() {
let _ = std::fs::remove_dir_all(&base);
}
/// #2479: a queued output the agent didn't name is derived from where the project sits, so on a
/// project that has never been saved it lands in the home folder, outside every automation root.
/// Queueing it there is refused, rather than leaving an item that can never be rendered.
#[test]
fn automation_roots_refuse_a_queued_output_from_an_unsaved_project() {
use crate::confine::Confinement;
let base = std::env::temp_dir().join(format!("ec-mcp-default-out-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let write = base.join("out");
std::fs::create_dir_all(&write).unwrap();
let p = |f: &str| write.join(f).to_string_lossy().into_owned();
let mut s = server();
s.backend().confine(Confinement::new(&[], std::slice::from_ref(&write)).unwrap());
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "A", "width": 64, "height": 64}}));
// Never saved: neither way of queueing a render may fall back to the home folder.
for command in ["renderQueue.add", "render.preRender", "file.createProxy"] {
let (c, err) = call(&mut s, "execute_command", json!({"command": command, "params": {"comp": "A"}}));
assert!(err, "`{command}` without an output should be refused: {c:?}");
let msg = c[0]["text"].as_str().unwrap_or_default();
assert!(msg.contains("the project has no file yet"), "`{command}`: {msg}");
}
// An array where a path goes: the engine reads a scalar, so this is no output either.
let (c, err) = call(&mut s, "execute_command", json!({"command": "file.createProxy", "params": {"comp": "A", "path": [p("arr.mov")]}}));
assert!(err, "an array where the output goes should be refused: {c:?}");
assert!(c[0]["text"].as_str().unwrap_or_default().contains("the project has no file yet"), "{c:?}");
// Naming an Output Module template instead of a file is the same: its own output is relative.
let (c, err) = call(&mut s, "execute_command", json!({"command": "render.preRender", "params": {"comp": "A", "output": "Lossless"}}));
assert!(err, "a template output should be refused too: {c:?}");
assert!(c[0]["text"].as_str().unwrap_or_default().contains("the project has no file yet"), "{c:?}");
let queue = call_json(&mut s, "execute_command", json!({"command": "renderQueue.list"}));
assert_eq!(queue["items"].as_array().map(Vec::len), Some(0), "nothing was queued: {queue}");
// Naming an output inside a write root is how it is done.
call_json(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "A", "output": p("named.mp4")}}));
// A proxy named outside the roots is refused. Whether that reads as a write or a read is
// #1347's to classify, so this asserts the refusal and not its wording. The path is a sibling
// of the write root rather than `/elsewhere/…`, which is not absolute on Windows and would be
// refused for that instead.
let outside = base.join("elsewhere").join("proxy.mov").to_string_lossy().into_owned();
let (c, err) = call(&mut s, "execute_command", json!({"command": "file.createProxy", "params": {"comp": "A", "path": outside}}));
assert!(err, "a proxy outside the roots should be refused: {c:?}");
assert!(c[0]["text"].as_str().unwrap_or_default().contains("outside the automation roots"), "{c:?}");
// Once the project is saved there, the default output (beside it) is queued as before.
call_json(&mut s, "save_project", json!({"path": p("proj.ecproj")}));
// A name that is not a path is an Output Module template, and the output it resolves to lands
// beside the saved project: inside the write root, so it is allowed (#1347 relies on this).
call_json(&mut s, "execute_command", json!({"command": "file.createProxy", "params": {"comp": "A", "path": "ProxyTemplate"}}));
call_json(&mut s, "execute_command", json!({"command": "renderQueue.add", "params": {"comp": "A"}}));
let queue = call_json(&mut s, "execute_command", json!({"command": "renderQueue.list"}));
let out = queue["items"][1]["outputPath"].as_str().unwrap_or_default();
assert!(out.starts_with(&*write.to_string_lossy()), "{queue}");
let _ = std::fs::remove_dir_all(&base);
}
/// #2131 (audit F-11, "Running code"): `script.run` and the Scripts menu are refused while file
/// access is confined, but a ScriptUI panel's script runs just as freely — when the panel opens,
/// when an event reaches one of its controls, or when one of its tasks comes due — and
@@ -1540,7 +1599,7 @@ fn blender_export_requires_an_absolute_path_in_a_write_root() {
}
let confinement = Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap();
let check = |path: &std::path::Path| confinement.check_command("camera.exportBlender", &json!({"path": path}));
assert_eq!(check(&write.join("scene.py")).unwrap(), None);
assert!(check(&write.join("scene.py")).unwrap().is_empty());
for path in [read.join("scene.py"), base.join("outside.py"), write.join("../read/scene.py")] {
assert!(check(&path).is_err(), "must deny {}", path.display());
}
+16
View File
@@ -362,6 +362,22 @@ mod tests {
assert!(a > 0.99 && c[0] > 0.5, "{c:?}");
}
/// #2102: negative Choke Matte keeps growing the matte past -20, down to -100: a 120 px hole
/// in an opaque frame closes a bit more with every step.
#[test]
fn simple_choker_grows_the_matte_at_large_negative_values() {
let mut img = Image::filled(200, 200, [1.0, 1.0, 1.0, 1.0]);
for y in 40..160 {
for x in 40..160 {
img.set(x, y, [0.0; 4]);
}
}
let filled = |c: f64| alpha_sum(&run("ec.matte.simplechoker", &[("chokeMatte", num(c))], img.clone()));
let sums: Vec<f32> = [-10.0, -20.0, -40.0, -60.0].into_iter().map(filled).collect();
assert!(sums.windows(2).all(|w| w[1] > w[0] + 100.0), "{sums:?}");
assert!((filled(-100.0) - 200.0 * 200.0).abs() < 1.0, "-100 closes the hole");
}
#[test]
fn matte_choker_defaults_shrink() {
let img = square();
+1 -1
View File
@@ -164,7 +164,7 @@ pub use enabled::always;
pub(crate) use enabled::{has_comp, has_keys, has_layers, has_project_selection, not_yet, not_yet_run};
pub use params::match_path_of;
pub(crate) use params::{
b_p, bad, comp_id, f_p, frontend, layer_mut, layer_p, layers_p, merge_p, popup_options, resolve_effect_path, resolve_layer, selectable,
b_p, bad, comp_id, f_p, frontend, layer_mut, layer_p, layers_or, layers_p, merge_p, popup_options, resolve_effect_path, resolve_layer, selectable,
selected_leaf_props, str_p, time_p, unlocked, visible_p,
};
pub use schema::{accepted_params, check_params, params_schema};
+35 -6
View File
@@ -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 }));
+3 -2
View File
@@ -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();
+6 -2
View File
@@ -243,7 +243,7 @@ fn add_keyframe(s: &mut Session, _: &Value) -> Result<Value> {
}
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let t = s.time();
let n = s.edit("Add Keyframe", None, |proj, _| {
let n = s.edit("Add Keyframe", None, |proj, state| {
let mut n = 0;
for (lid, uid) in &targets {
let l = layer_mut(proj, cid, *lid)?;
@@ -259,6 +259,9 @@ fn add_keyframe(s: &mut Session, _: &Value) -> Result<Value> {
pr.set_value_at(lt, v);
}
n += 1;
if pr.match_id == "type" {
super::prop::sync_polystar_property(proj, state, cid, *lid, *uid, t)?;
}
}
Ok(n)
})?;
@@ -268,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
+43 -11
View File
@@ -6,7 +6,7 @@ use effectcraft_project::{Comp, Expression, ItemId, ItemKind, Layer, LayerId, La
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, has_comp, has_layers, layers_p, match_path_of, selected_leaf_props, str_p};
use super::{CommandSpec, has_comp, has_layers, layers_or, layers_p, match_path_of, selected_leaf_props, str_p};
use crate::{EngineError, KeyRef, LinkClip, Result, Session, cmd};
fn can_undo(s: &Session) -> std::result::Result<(), String> {
@@ -104,9 +104,16 @@ pub(crate) fn reid(layer: &mut Layer, next: &mut u64) {
*next += 1;
}
/// `edit.duplicate`: copies the targets in place. Without `layers` the targets are, in order:
/// during a text edit the edited layer (the edit is committed first, as `text.endEdit` does),
/// else the selected effects, shape items or text animators, else the selected layers. Layer
/// copies are selected and their ids returned; the other paths return their delegated result.
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
// Effects selected → duplicate them (Edit ▸ Duplicate in Effect Controls).
if p.get("layers").is_none() {
let editing = s.state.text_edit.is_some();
// Effects selected → duplicate them (Edit ▸ Duplicate in Effect Controls). An open text edit
// comes first, as it does for Clear: opening the editor keeps the property selection, but
// while typing, Duplicate means the edited layer, not an effect or item selected before.
if p.get("layers").is_none() && !editing {
let fx = super::effect::selected_effects(s);
if !fx.is_empty() {
let mut out = vec![];
@@ -126,7 +133,17 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
return super::prop_groups::duplicate_contents(s, &items);
}
}
let (cid, ids) = layers_p(s, p)?;
// A deselected layer never keeps an open text edit (#2226): the copies take the selection, so
// commit an open edit first, exactly as `text.endEdit` and Deselect All do — one call on the
// command path so every route (menu, shortcut, palette, MCP, script) ends the edit and leaves
// the same final state. Unnamed, the target is the edited layer even when an engine route
// selected another one meanwhile.
let edited = s.state.text_edit.as_ref().map(|e| e.layer);
super::text_edit::end_edit(s, &Value::Null)?;
let (cid, ids) = match edited {
Some(layer) => layers_or(s, p, &[layer])?,
None => layers_p(s, p)?,
};
let new = s.edit("Duplicate", None, |proj, st| {
let mut next = proj.next_id;
let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?;
@@ -143,14 +160,16 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
}
relink_copies(comp, &map);
proj.next_id = next;
st.selected_layers = created.clone();
// The property, keyframe and mask-vertex selection the edit kept belongs to the originals;
// left in place, the next Duplicate or Clear would act on it, not on the copies. So during
// an edit the copies take the selection the way a click on them would.
if editing {
super::layer::select_only(st, created.clone());
} else {
st.selected_layers = created.clone();
}
Ok(created)
})?;
// Duplicating selects the copies, so an open text edit on a now-deselected layer ends
// (#2226): typing must not keep editing an unselected layer.
if s.state.text_edit.as_ref().is_some_and(|e| !s.state.selected_layers.contains(&e.layer)) {
super::text_edit::end_edit(s, &Value::Null)?;
}
Ok(json!(new.iter().map(|l| l.0).collect::<Vec<_>>()))
}
@@ -968,7 +987,15 @@ pub fn specs() -> Vec<CommandSpec> {
paste_reversed
),
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", layers_or_keys, delete),
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?} (effects or shape items when they are selected)", has_layers, duplicate),
cmd!(
"edit.duplicate",
"Duplicate",
["Edit"],
Some("Cmd+D"),
"{layers?} (without layers: during a text edit the edited layer, else the selected effects, shape items or text animators, else the selected layers)",
layers_or_text_edit,
duplicate
),
cmd!("edit.splitLayer", "Split Layer", ["Edit"], Some("Cmd+Shift+D"), "{layers?}", has_layers, split),
cmd!("edit.liftWorkArea", "Lift Work Area", ["Edit"], None, "{layers?}", has_comp, lift),
cmd!("edit.extractWorkArea", "Extract Work Area", ["Edit"], None, "{layers?}", has_comp, extract),
@@ -1038,6 +1065,11 @@ fn layers_or_keys(s: &Session) -> std::result::Result<(), String> {
if !s.state.selected_keys.is_empty() || !s.state.selected_vertices.is_empty() { super::has_comp(s) } else { has_layers(s) }
}
/// Duplicate's unnamed target: the edited layer during a text edit, else the selection.
fn layers_or_text_edit(s: &Session) -> std::result::Result<(), String> {
if s.state.text_edit.is_some() { super::has_comp(s) } else { has_layers(s) }
}
fn always_ok(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
+7
View File
@@ -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"));
+2
View File
@@ -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 {
+1
View File
@@ -28,6 +28,7 @@ pub(crate) use create::{color_p, index_p, insert_layer, insert_layer_at, place,
pub(crate) use parent::parent_fixes;
use parent::set_parent;
use replace::{has_replace_source, replace_source};
pub(crate) use select::select_only;
use select::{select, select_next, select_prev, select_step};
use switches::{blend, rename, set_comment, set_switch, track_matte};
use timing::{arrange, move_frames, precompose, slip, timing};
+11 -5
View File
@@ -25,15 +25,21 @@ pub(super) fn select(s: &mut Session, p: &Value) -> Result<Value> {
}
}
} else {
s.state.selected_layers = ids;
s.state.selected_props.clear();
s.state.selected_keys.clear();
let keep = s.state.selected_layers.clone();
s.state.selected_vertices.retain(|v| keep.contains(&v.layer));
select_only(&mut s.state, ids);
}
Ok(json!(s.state.selected_layers.iter().map(|l| l.0).collect::<Vec<_>>()))
}
/// Make `ids` the whole layer selection, as a plain click on them does: no property or keyframe
/// stays selected, and mask vertices stay selected only on layers that are still selected.
pub(crate) fn select_only(st: &mut crate::EditorState, ids: Vec<LayerId>) {
st.selected_layers = ids;
st.selected_props.clear();
st.selected_keys.clear();
let keep = st.selected_layers.clone();
st.selected_vertices.retain(|v| keep.contains(&v.layer));
}
pub(super) fn select_step(s: &mut Session, p: &Value, dir: i64) -> Result<Value> {
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
if comp.layers.is_empty() {
+7 -5
View File
@@ -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();
+6 -1
View File
@@ -148,6 +148,11 @@ pub(crate) fn layer_p(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, Laye
/// `layers` param (array) or the selection.
pub(crate) fn layers_p(s: &Session, p: &Value) -> Result<(ItemId, Vec<LayerId>)> {
layers_or(s, p, &s.state.selected_layers)
}
/// `layers` param (array), else `layer`, else those of `fallback` still in the comp.
pub(crate) fn layers_or(s: &Session, p: &Value, fallback: &[LayerId]) -> Result<(ItemId, Vec<LayerId>)> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
// Explicit references that match no layer are an error (agents otherwise get a silent no-op).
@@ -159,7 +164,7 @@ pub(crate) fn layers_p(s: &Session, p: &Value) -> Result<(ItemId, Vec<LayerId>)>
if let Some(v) = p.get("layer").filter(|v| !v.is_null()) {
return Ok((cid, vec![resolve(v)?]));
}
Ok((cid, s.state.selected_layers.iter().copied().filter(|l| comp.layer(*l).is_some()).collect()))
Ok((cid, fallback.iter().copied().filter(|l| comp.layer(*l).is_some()).collect()))
}
/// Whether a layer can be picked by the user (Select All, Ctrl+Up/Down, a Timeline click): not
+111 -19
View File
@@ -48,7 +48,6 @@ fn with_prop<T>(
let t = s.time_of(cid);
let linear = s.prefs.general.default_spatial_linear;
s.edit(label, merge, |proj, state| {
let mut next = proj.next_id;
let l = layer_mut(proj, cid, lid)?;
let lt = l.layer_time(t);
let pr = l.props.find_mut(uid).ok_or_else(|| bad("prop", "property vanished"))?;
@@ -64,17 +63,37 @@ fn with_prop<T>(
}
}
if pr.match_id == "type" {
let removed = sync_polystar_type(l, uid, lt, &mut next)?;
if !removed.is_empty() {
state.selected_props.retain(|(layer, prop)| *layer != lid || !removed.contains(prop));
state.selected_keys.retain(|key| key.layer != lid || !removed.contains(&key.prop));
}
sync_polystar_property(proj, state, cid, lid, uid, t)?;
}
proj.next_id = next;
Ok(r)
})
}
/// Keep Polystar controls and their selection references in the property's edit transaction.
pub(super) fn sync_polystar_property(
proj: &mut effectcraft_project::Project,
state: &mut crate::EditorState,
cid: ItemId,
lid: LayerId,
uid: Uid,
time: Tick,
) -> Result<()> {
let mut next = proj.next_id;
let layer = layer_mut(proj, cid, lid)?;
let removed = sync_polystar_type(layer, uid, layer.layer_time(time), &mut next)?;
if !removed.is_empty() {
if state.active_comp == Some(cid) {
state.selected_props.retain(|(layer, prop)| *layer != lid || !removed.contains(prop));
state.selected_keys.retain(|key| key.layer != lid || !removed.contains(&key.prop));
} else if let Some(selection) = state.comp_selections.get_mut(&cid) {
selection.props.retain(|(layer, prop)| *layer != lid || !removed.contains(prop));
selection.keys.retain(|key| key.layer != lid || !removed.contains(&key.prop));
}
}
proj.next_id = next;
Ok(())
}
/// A static Polygon has no inner controls; switching to Star creates them in schema order
/// with AE's switch defaults (#1685). Animated/expression-driven types need the controls at
/// every time, including when the current value is Polygon.
@@ -228,7 +247,8 @@ fn property_reply(p: &Value, layer: &Layer, pr: &Property, time: Tick, value: Va
/// property (by match path, `transform/position`, `effects/<effect>#n/<param>`) of every other
/// selected, unlocked layer of the comp whose value is of the same kind, as (layer, uid). Empty
/// when `selected` is not set or the edited layer isn't selected. After Effects applies a value
/// edit, the stopwatch and the keyframe button to all selected layers (#355, #491).
/// edit, the stopwatch and the keyframe button to all selected layers (#355, #491). A parameter
/// of a selected Puppet pin also reaches the same parameter of the other selected pins (#1751).
fn selected_twins(s: &Session, p: &Value, cmd: &str) -> Result<Vec<(LayerId, Uid)>> {
if b_p(p, "selected") != Some(true) {
return Ok(vec![]);
@@ -241,14 +261,31 @@ fn selected_twins(s: &Session, p: &Value, cmd: &str) -> Result<Vec<(LayerId, Uid
return Ok(vec![]);
}
let kind = (pr.value.kind_name(), pr.value.components().len());
Ok(s.state
let same_kind = |q: &&Property| (q.value.kind_name(), q.value.components().len()) == kind;
let mut twins: Vec<(LayerId, Uid)> = s
.state
.selected_layers
.iter()
.filter(|o| **o != lid)
.filter_map(|o| comp.layer(*o))
.filter(|l| !l.switches.locked)
.filter_map(|l| l.props.prop(&path).filter(|q| (q.value.kind_name(), q.value.components().len()) == kind).map(|q| (l.id, q.uid)))
.collect())
.filter_map(|l| l.props.prop(&path).filter(same_kind).map(|q| (l.id, q.uid)))
.collect();
// A parameter of a selected Puppet pin: the same parameter of every other selected pin, on
// this layer too, so several pins move or key together (#1751).
let pin = super::puppet::layer_pins(layer).find(|g| g.props().any(|q| q.uid == uid)).map(|g| g.uid);
if let Some(pin) = pin
&& s.state.selected_props.contains(&(lid, pin))
{
for (ol, ou) in &s.state.selected_props {
let Some(other) = comp.layer(*ol).filter(|l| !l.switches.locked) else { continue };
let Some(q) = super::puppet::layer_pins(other).find(|g| g.uid == *ou).and_then(|g| g.get(&pr.match_id)).filter(same_kind) else { continue };
if (*ol, q.uid) != (lid, uid) && !twins.contains(&(*ol, q.uid)) {
twins.push((*ol, q.uid));
}
}
}
Ok(twins)
}
/// `p` aimed at property `uid` of layer `lid` instead.
@@ -267,8 +304,13 @@ fn twin_params(p: &Value, lid: LayerId, uid: Uid) -> Value {
/// The value of property `uid` of `lid` at the CTI as numbers (Position with Separate
/// Dimensions: its X / Y / Z Position).
fn current_components(s: &Session, cid: ItemId, lid: LayerId, uid: Uid) -> Option<Vec<f64>> {
components_at(s, cid, lid, uid, None)
}
/// [`current_components`] at layer time `at` (the CTI when `None`).
fn components_at(s: &Session, cid: ItemId, lid: LayerId, uid: Uid, at: Option<Tick>) -> Option<Vec<f64>> {
let l = s.project.comp(cid)?.layer(lid)?;
let lt = l.layer_time(s.time_of(cid));
let lt = at.unwrap_or_else(|| l.layer_time(s.time_of(cid)));
if let Some(tr) = l.transform()
&& tr.get("position").is_some_and(|q| q.uid == uid)
&& tr.get("positionX").is_some()
@@ -300,7 +342,9 @@ pub(crate) fn proportional_share(index: usize, count: usize) -> f64 {
/// `prop.set`; with `selected`, also on the other selected layers: the same value, or with
/// `offset` (a scrub) the same change from each layer's own value. A typed value with `dims`
/// (the components edited, e.g. `[2]` for Z) sets only those components on the other layers,
/// which keep the rest of their own value (#1434). With `offset` and
/// which keep the rest of their own value (#1434); `follow` components move on each layer by the
/// change its first typed component takes (linked Audio Levels keep each layer's balance, #2485).
/// With `offset` and
/// `proportional` (Proportional Scrubbing) each layer takes [`proportional_share`] of the change
/// by its place in the selection order, the scrubbed layer too. One undo step (with `merge`,
/// one that a continuing scrub keeps folding into).
@@ -325,10 +369,19 @@ fn set_selected(s: &mut Session, p: &Value) -> Result<Value> {
(None, Some(a), Some(_)) if !scalar => Some(a.iter().filter_map(Value::as_u64).filter_map(|d| usize::try_from(d).ok()).collect()),
_ => None,
};
let follow: Vec<usize> =
p.get("follow").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).filter_map(|d| usize::try_from(d).ok()).collect();
// Each layer's own value where `set` writes it (a `time` other than the CTI keys there).
let typed = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid| {
if let (Some(dims), Some(n)) = (&dims, &new)
&& let Some(mut c) = current_components(s, cid, olid, ouid)
&& let Some(mut c) = components_at(s, cid, olid, ouid, snapped_key_time(s, cid, olid, q, "prop.set").ok().flatten())
{
let change = dims.first().and_then(|&d| Some(n.get(d)? - c.get(d)?)).unwrap_or(0.0);
for &d in &follow {
if let Some(slot) = c.get_mut(d) {
*slot += change;
}
}
for &d in dims {
if let (Some(slot), Some(v)) = (c.get_mut(d), n.get(d)) {
*slot = *v;
@@ -664,12 +717,21 @@ fn set_expr(s: &mut Session, p: &Value) -> Result<Value> {
s.project.comp(*cid).and_then(|c| c.layer(*lid).and_then(|l| super::link::reference(c, l, l, *uid, true))).unwrap_or_else(|| "value".into())
})
.collect();
let results = s.edit("Expression", merge_p(p), |proj, _| {
let times: Vec<_> = targets.iter().map(|(cid, _, _)| s.time_of(*cid)).collect();
let results = s.edit("Expression", merge_p(p), |proj, state| {
let mut out = vec![];
for ((cid, lid, uid), def) in targets.iter().zip(&defs) {
let mut changed_types = vec![];
for (((cid, lid, uid), def), time) in targets.iter().zip(&defs).zip(&times) {
let pr = layer_mut(proj, *cid, *lid)?.props.find_mut(*uid).ok_or_else(|| bad(C, "property vanished"))?;
apply_expr(pr, &text, enabled, def);
out.push(json!(pr.expr.as_ref().map(|e| e.text.clone())));
if pr.match_id == "type" {
changed_types.push((*cid, *lid, *uid, *time));
}
}
// All selected targets must be edited before a Type change can remove inner controls.
for (cid, lid, uid, time) in changed_types {
sync_polystar_property(proj, state, cid, lid, uid, time)?;
}
Ok(out)
})?;
@@ -886,6 +948,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)?;
@@ -898,7 +969,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);
@@ -1045,10 +1125,16 @@ fn move_keys(s: &mut Session, p: &Value) -> Result<Value> {
fn delete_keys(s: &mut Session, _: &Value) -> Result<Value> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let sel = s.state.selected_keys.clone();
let time = s.time();
s.edit("Delete Keyframes", None, |proj, st| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
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)
{
@@ -1057,9 +1143,15 @@ fn delete_keys(s: &mut Session, _: &Value) -> Result<Value> {
}
pr.keys.remove(i);
removed += 1;
if pr.match_id == "type" {
changed.insert((k.layer, k.prop));
}
}
}
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)?;
}
// The keys actually removed: a selected key that is gone (or listed twice) isn't counted,
// so a caller can tell that a delete changed nothing.
Ok(json!(removed))
@@ -1330,7 +1422,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Property Value",
[],
None,
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all), dims? (with selected, a typed value: the components edited, e.g. [2] for Z; the other layers keep the rest of their own value)}",
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all), dims? (with selected, a typed value: the components edited, e.g. [2] for Z; the other layers keep the rest of their own value), follow? (with dims: components that move on each other layer by the change its first typed component takes)}",
has_layers,
set_selected
),
+8 -5
View File
@@ -28,6 +28,9 @@ impl Session {
/// Drop selections that no longer exist. Each comp keeps its own selection (#1001); a layer id
/// counts only inside the comp that owns it, so the active comp never sees another comp's layers.
/// Validate property UIDs too: undo can remove a control and rewind the allocator, so keeping
/// its selection could silently select a different property when the UID is reused. Group
/// selections remain valid even though groups are not leaf properties.
pub fn sanitize_state(&mut self) {
let p = self.project.clone();
self.state.open_comps.retain(|c| p.comp(*c).is_some());
@@ -35,8 +38,8 @@ impl Session {
let Some(c) = p.comp(*cid) else { return false };
let ids: Vec<LayerId> = c.layers.iter().map(|l| l.id).collect();
sel.layers.retain(|l| ids.contains(l));
sel.props.retain(|(l, _)| ids.contains(l));
sel.keys.retain(|k| ids.contains(&k.layer));
sel.props.retain(|(lid, uid)| c.layer(*lid).is_some_and(|l| l.props.find(*uid).is_some() || l.props.find_group(*uid).is_some()));
sel.keys.retain(|k| c.layer(k.layer).is_some_and(|l| l.props.find(k.prop).is_some()));
sel.vertices.retain(|v| ids.contains(&v.layer));
!sel.is_empty()
});
@@ -49,11 +52,11 @@ impl Session {
self.recall_selection(id);
}
}
if let Some(c) = self.active_comp() {
if let Some(c) = self.state.active_comp.and_then(|cid| p.comp(cid)) {
let ids: Vec<LayerId> = c.layers.iter().map(|l| l.id).collect();
self.state.selected_layers.retain(|l| ids.contains(l));
self.state.selected_props.retain(|(l, _)| ids.contains(l));
self.state.selected_keys.retain(|k| ids.contains(&k.layer));
self.state.selected_props.retain(|(lid, uid)| c.layer(*lid).is_some_and(|l| l.props.find(*uid).is_some() || l.props.find_group(*uid).is_some()));
self.state.selected_keys.retain(|k| c.layer(k.layer).is_some_and(|l| l.props.find(k.prop).is_some()));
self.state.selected_vertices.retain(|v| ids.contains(&v.layer));
}
if let Some((l, t)) = self.state.current_track
+74
View File
@@ -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();
+204
View File
@@ -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");
}
+45
View File
@@ -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.
+39
View File
@@ -308,6 +308,45 @@ fn puppet_select_all_takes_the_pin_kind_and_several_pins_record_together() {
assert!(s.execute("puppet.recordPin", json!({"layer": id, "pin": a, "pins": [st], "samples": samples})).is_err());
}
/// Editing a parameter of one of several selected pins edits the same parameter of the others,
/// in one undo step; unselected pins keep theirs (#1751).
#[test]
fn editing_one_selected_pin_edits_the_other_selected_pins() {
let (mut s, id) = setup();
let pin = |s: &mut Session, x: i64| s.execute("puppet.addPin", json!({"layer": id, "position": [x, 30]})).unwrap()["pin"].as_u64().unwrap();
let (a, b, c) = (pin(&mut s, 10), pin(&mut s, 50), pin(&mut s, 90));
let position = |s: &Session, pin: u64| layer(s, id).props.find_group(pin).unwrap().get("position").unwrap().clone();
let (pa, pb) = (position(&s, a).uid, position(&s, b).uid);
let at = |s: &Session, pin: u64| position(s, pin).value_at(Tick::ZERO).components();
s.execute("prop.select", json!({"layer": id, "prop": a, "selectKeys": false})).unwrap();
s.execute("prop.select", json!({"layer": id, "prop": b, "selectKeys": false, "add": true})).unwrap();
// A typed value lands on both selected pins, and one Undo takes it back.
let steps = s.history.undo.len();
s.execute("prop.set", json!({"layer": id, "prop": pa, "value": [20, 40], "selected": true})).unwrap();
assert_eq!((at(&s, a), at(&s, b), at(&s, c)), (vec![20.0, 40.0], vec![20.0, 40.0], vec![90.0, 30.0]));
assert_eq!(s.history.undo.len(), steps + 1, "one undo step");
s.undo();
assert_eq!((at(&s, a), at(&s, b)), (vec![10.0, 30.0], vec![50.0, 30.0]));
// A scrub moves each selected pin by the same change from its own value.
s.execute("prop.set", json!({"layer": id, "prop": pb, "value": [55, 25], "selected": true, "offset": true})).unwrap();
assert_eq!((at(&s, a), at(&s, b), at(&s, c)), (vec![15.0, 25.0], vec![55.0, 25.0], vec![90.0, 30.0]));
// The stopwatch follows on both (a new pin starts keyed, so the toggle turns it off).
let animated = |s: &Session| [a, b, c].map(|p| position(s, p).is_animated());
assert_eq!(animated(&s), [true; 3]);
s.execute("prop.toggleAnimation", json!({"layer": id, "prop": pa, "selected": true})).unwrap();
assert_eq!(animated(&s), [false, false, true]);
// Without `selected`, or with only one pin selected, the edit stays on its own pin.
s.execute("prop.set", json!({"layer": id, "prop": pa, "value": [0, 0]})).unwrap();
assert_eq!(at(&s, b), vec![55.0, 25.0]);
s.execute("prop.select", json!({"layer": id, "prop": a, "selectKeys": false})).unwrap();
s.execute("prop.set", json!({"layer": id, "prop": pa, "value": [5, 5], "selected": true})).unwrap();
assert_eq!(at(&s, b), vec![55.0, 25.0]);
}
fn expr_of(s: &Session, layer: u64, uid: u64) -> String {
let l = layer_of(s, layer);
l.props.find(uid).and_then(|p| p.expr.as_ref()).map(|e| e.text.clone()).unwrap_or_default()
+182 -3
View File
@@ -7,6 +7,7 @@ use serde_json::json;
use crate::Session;
use crate::commands::text_edit::{MAX_IME_TEXT_BYTES, ime_stamp_revision, ime_surrounding, layer_doc};
use crate::tests::three_d::layer_count;
fn session(text: &str) -> (Session, u64) {
let mut s = Session::default();
@@ -1231,10 +1232,188 @@ fn deselect_all_keeps_an_empty_type_tool_layer() {
s.state.text_edit.as_ref().is_some_and(|e| e.created && e.layer == effectcraft_project::LayerId(new)),
"the Type tool opens the edit on its new, empty layer"
);
let count = |s: &Session| s.active_comp().map_or(0, |c| c.layers.len());
let before = count(&s);
let before = layer_count(&s);
s.execute("edit.deselectAll", json!({})).unwrap();
assert!(s.state.text_edit.is_none());
assert!(s.state.selected_layers.is_empty());
assert_eq!(count(&s), before, "an empty Type-tool layer is kept, as in After Effects");
assert_eq!(layer_count(&s), before, "an empty Type-tool layer is kept, as in After Effects");
}
#[test]
fn duplicate_ends_an_edit_on_already_committed_text() {
// The engine route (MCP, control channel, script) with text that was committed before the
// edit opened and nothing typed inside it: the edit still ends and the copy is selected.
let (mut s, t) = editing("Hello", 5);
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
assert!(s.state.text_edit.is_none(), "the open edit ends on a direct edit.duplicate");
assert_eq!(s.state.selected_layers, vec![effectcraft_project::LayerId(copy)], "and the copy is selected");
assert_eq!(doc(&s, t).text, "Hello", "the committed text is kept on the original");
assert_eq!(doc(&s, copy).text, "Hello", "and carried by the copy");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer_count(&s), 1, "undo removes the copy");
assert!(s.state.text_edit.is_none(), "undo does not reopen the edit");
}
#[test]
fn duplicate_during_an_edit_copies_the_layer_not_a_selected_property_group() {
use effectcraft_project::LayerId;
// Opening the editor by command, as a Type-tool click does, keeps the property selection, so
// an effect, shape item or text animator can still be selected while the user types.
for what in ["effect", "shape item", "text animator"] {
let (mut s, t) = session("Hello");
let (owner, path) = match what {
"effect" => {
s.execute("effect.apply", json!({"layers": [t], "effect": "Gaussian Blur"})).unwrap();
(t, "effects")
}
"shape item" => (s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap(), "contents"),
_ => {
s.execute("layer.addTextAnimator", json!({"layer": t, "properties": ["opacity"]})).unwrap();
(t, "text/animators")
}
};
let items =
|s: &Session| s.active_comp().unwrap().layer(LayerId(owner)).unwrap().props.group(path).unwrap().groups().map(|g| g.uid).collect::<Vec<_>>();
let item = items(&s)[0];
let n = items(&s).len();
let before = layer_count(&s);
s.execute("prop.select", json!({"layer": owner, "prop": item})).unwrap();
begin_edit_at(&mut s, t, 5);
let r = s.execute("edit.duplicate", json!({})).unwrap();
assert!(s.state.text_edit.is_none(), "{what}: Duplicate ends the open edit");
assert_eq!(items(&s).len(), n, "{what}: the selected {what} is not duplicated");
assert_eq!(layer_count(&s), before + 1, "{what}: the edited layer is");
assert_eq!(s.state.selected_layers, vec![LayerId(r[0].as_u64().unwrap())], "{what}: and its copy is selected");
// The selection the edit passed over goes with it: the next Duplicate copies the selected
// copy rather than reaching back to the original's {what}.
s.execute("edit.duplicate", json!({})).unwrap();
assert_eq!(items(&s).len(), n, "{what}: a second Duplicate leaves the original's {what} alone");
assert_eq!(layer_count(&s), before + 2, "{what}: and copies the copy");
// With no edit open, the selected item keeps its precedence over the layers.
s.execute("prop.select", json!({"layer": owner, "prop": item})).unwrap();
s.execute("edit.duplicate", json!({})).unwrap();
assert_eq!(items(&s).len(), n + 1, "{what}: outside an edit Duplicate copies the selected {what}");
assert_eq!(layer_count(&s), before + 2, "{what}: and no layer");
}
}
#[test]
fn duplicate_during_an_edit_leaves_no_key_or_vertex_selected_on_the_original() {
use effectcraft_project::LayerId;
// Opening the editor keeps the keyframe and mask-vertex selection too. Once the copy takes the
// selection, Clear deletes the copy, not a key or a vertex of the original.
for what in ["keyframe", "mask vertex"] {
let (mut s, t) = session("Hello");
let mask = match what {
"keyframe" => {
for time in [0.0, 1.0] {
s.execute("prop.addKey", json!({"layer": t, "path": "transform/opacity", "time": time, "value": 50})).unwrap();
}
s.execute("keys.select", json!({"keys": [{"layer": t, "path": "transform/opacity", "time": 0.0}]})).unwrap();
None
}
// The Pen leaves the new mask's last vertex selected.
_ => Some(s.execute("mask.new", json!({"layer": t, "vertices": [[0, 0], [40, 0], [40, 40]], "closed": true})).unwrap()["mask"].as_u64().unwrap()),
};
let original = |s: &Session| {
let l = s.active_comp().unwrap().layer(LayerId(t)).unwrap();
match mask {
None => l.props.prop("transform/opacity").unwrap().keys.len(),
Some(m) => l.props.find_group(m).unwrap().get("path").unwrap().value.as_path().unwrap().vertices.len(),
}
};
let (n, before) = (original(&s), layer_count(&s));
begin_edit_at(&mut s, t, 5);
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "{what}: the copy is selected");
s.execute("edit.clear", json!({})).unwrap();
assert_eq!(layer_count(&s), before, "{what}: Clear deletes the selected copy");
assert_eq!(original(&s), n, "{what}: and leaves the original's {what} alone");
}
}
#[test]
fn duplicate_during_an_edit_copies_the_edited_layer_after_another_is_selected() {
use effectcraft_project::LayerId;
// An engine route (MCP, control channel, script) can select another layer while the edit
// stays open. The implicit Duplicate still means the edited layer, as Clear does; a named
// target keeps its meaning.
let (mut s, t) = session("Hello");
let other = s.execute("layer.newText", json!({"text": "Other"})).unwrap()["layer"].as_u64().unwrap();
begin_edit_at(&mut s, t, 5);
s.execute("layer.select", json!({"layer": other})).unwrap();
assert!(s.state.text_edit.as_ref().is_some_and(|e| e.layer == LayerId(t)), "selecting another layer leaves the edit open");
let before = layer_count(&s);
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
assert!(s.state.text_edit.is_none(), "Duplicate ends the open edit");
assert_eq!(layer_count(&s), before + 1, "one layer is copied");
assert_eq!(doc(&s, copy).text, "Hello", "the edited layer is copied, not the newly selected one");
assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "and its copy is selected");
// Null parameters name no layer, as everywhere else layers are read.
for unnamed in [json!({"layers": null}), json!({"layer": null})] {
begin_edit_at(&mut s, t, 5);
s.execute("layer.select", json!({"layer": other})).unwrap();
let copy = s.execute("edit.duplicate", unnamed.clone()).unwrap()[0].as_u64().unwrap();
assert_eq!(doc(&s, copy).text, "Hello", "{unnamed}: the edited layer is copied");
}
begin_edit_at(&mut s, t, 5);
let named = s.execute("edit.duplicate", json!({"layer": other})).unwrap()[0].as_u64().unwrap();
assert_eq!(doc(&s, named).text, "Other", "a named layer is copied as named");
}
#[test]
fn duplicate_during_an_edit_with_nothing_selected_copies_the_edited_layer() {
use effectcraft_project::LayerId;
// An engine route can also clear the selection while the edit stays open. Duplicate stays
// available, since its unnamed target is the edited layer, not the selection.
let (mut s, _) = editing("Hello", 5);
s.execute("layer.select", json!({"layers": []})).unwrap();
assert!(s.state.selected_layers.is_empty() && s.state.text_edit.is_some(), "the edit is open with nothing selected");
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
assert!(s.state.text_edit.is_none(), "Duplicate ends the open edit");
assert_eq!(doc(&s, copy).text, "Hello", "and copies the edited layer");
assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "which is selected");
// With no edit open, nothing selected still means nothing to duplicate.
s.execute("layer.select", json!({"layers": []})).unwrap();
let before = layer_count(&s);
assert!(s.execute("edit.duplicate", json!({})).is_err(), "outside an edit Duplicate needs a selection");
assert_eq!(layer_count(&s), before, "and copies nothing");
}
#[test]
fn duplicate_ends_the_open_edit_and_selects_the_copy() {
let (mut s, t) = editing("Hello", 5);
s.execute("text.insert", json!({"text": "!"})).unwrap();
assert_eq!(doc(&s, t).text, "Hello!");
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
// A deselected layer never keeps an open text edit: Duplicate commits the edit through the
// same route as text.endEdit before the selection moves, so every route (menu, shortcut,
// palette, MCP, script) leaves one final state.
assert!(s.state.text_edit.is_none(), "Duplicate ends the open text edit, as Deselect All does");
assert_eq!(s.state.selected_layers, vec![effectcraft_project::LayerId(copy)], "and selects the copy");
assert_eq!(doc(&s, t).text, "Hello!", "the original keeps the typed text");
assert_eq!(doc(&s, copy).text, "Hello!", "the copy carries it too");
// The edit ending is no undoable step of its own (as with text.endEdit): undo takes the
// duplicate, it does not reopen the edit or undo the typing.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer_count(&s), 1, "undo removed the copy");
assert_eq!(doc(&s, t).text, "Hello!", "the typed text stays committed");
assert!(s.state.text_edit.is_none(), "undo does not reopen the edit");
// With no open edit Duplicate is untouched by all of this: same behavior, no extra steps.
s.execute("edit.duplicate", json!({"layers": [t]})).unwrap();
assert_eq!(layer_count(&s), 2, "a plain duplicate still duplicates");
assert!(s.state.text_edit.is_none());
}
#[test]
fn duplicate_of_an_empty_type_tool_layer_ends_the_edit_and_copies_it() {
use effectcraft_project::LayerId;
let (mut s, _keep) = session("Hello");
let new = s.execute("layer.newText", json!({"text": "", "position": [80.0, 80.0], "edit": true})).unwrap()["layer"].as_u64().unwrap();
assert!(s.state.text_edit.as_ref().is_some_and(|e| e.created && e.layer == LayerId(new)), "the Type tool opens the edit on its new, empty layer");
let before = layer_count(&s);
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
assert!(s.state.text_edit.is_none(), "the edit ends the way text.endEdit ends it");
assert_eq!(layer_count(&s), before + 1, "the empty layer is kept, as text.endEdit keeps it, and copied");
assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "and the copy is selected");
}
+2 -1
View File
@@ -26,7 +26,8 @@ fn auto_orient_command() {
assert!(!effectcraft_render::three_d::camera::is_two_node(&s.active_comp().unwrap().layers[0]));
}
fn layer_count(s: &Session) -> usize {
/// Layers in the active comp.
pub(crate) fn layer_count(s: &Session) -> usize {
s.active_comp().unwrap().layers.len()
}
+73
View File
@@ -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();
@@ -1483,3 +1530,29 @@ fn a_typed_component_applies_only_that_component_to_the_selected_layers() {
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [1.0, 2.0, 3.0], "selected": true})).unwrap();
assert_eq!(pos(&s, c), vec![1.0, 2.0, 3.0]);
}
/// #2485: linked Audio Levels typed with several layers selected: each takes the typed Left, and
/// its Right moves by its own change, so every layer keeps its own stereo balance.
#[test]
fn a_typed_component_moves_its_follow_components_by_each_layers_own_change() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Sound", "width": 64, "height": 36, "duration": 4})).unwrap();
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap();
let a = s.execute("layer.addItem", json!({"item": "Sound"})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.addItem", json!({"item": "Sound"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": a, "path": "audio/levels", "value": [-6.0, -12.0]})).unwrap();
s.execute("prop.set", json!({"layer": b, "path": "audio/levels", "value": [-20.0, -30.0]})).unwrap();
s.execute("layer.select", json!({"layers": [a, b]})).unwrap();
let uid = prop(&s, a, "audio/levels").uid;
let undo = s.history.undo.len();
s.execute_checked("prop.set", json!({"layer": a, "prop": uid, "value": [-3.0, -9.0], "selected": true, "dims": [0], "follow": [1]})).unwrap();
assert_eq!(prop(&s, a, "audio/levels").value.components(), vec![-3.0, -9.0]);
assert_eq!(prop(&s, b, "audio/levels").value.components(), vec![-3.0, -13.0], "B rises 17 dB on both channels");
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
// Keyed at a time other than the CTI: B's change is measured on its key there.
animate(&mut s, b, "audio/levels", &[(0.0, json!([-20.0, -30.0])), (1.0, json!([-10.0, -40.0]))]);
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [-4.0, -10.0], "time": 1.0, "selected": true, "dims": [0], "follow": [1]})).unwrap();
let keys: Vec<Vec<f64>> = prop(&s, b, "audio/levels").keys.iter().map(|k| k.value.components()).collect();
assert_eq!(keys, vec![vec![-20.0, -30.0], vec![-4.0, -34.0]], "B's 1 s key rises 6 dB on both channels");
}
+163
View File
@@ -697,3 +697,166 @@ fn polystar_animated_type_keeps_the_inner_controls_needed_by_other_frames() {
assert_eq!(star.get("type").unwrap().value_at(Tick::from_seconds_f64(1.0)), KV::Enum(1));
assert_eq!(star.get("innerRadius").unwrap().value, KV::Scalar(27.0));
}
#[test]
fn polystar_expression_routes_synchronize_controls_in_the_same_undo_step() {
for explicit in [false, true] {
let mut s = comp();
let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap();
let group = |s: &Session| layer(s, lid).props.group("contents/group/contents/star").unwrap().clone();
let before = group(&s);
let uid = before.get("type").unwrap().uid;
s.state.selected_props = vec![(LayerId(lid), uid)];
let params = if explicit { json!({"layer": lid, "prop": uid, "expression": "1"}) } else { json!({"expression": "1"}) };
s.execute("prop.setExpression", params).unwrap();
let animated = group(&s);
assert!(animated.get("innerRadius").is_some() && animated.get("innerRoundness").is_some());
assert!(s.undo());
assert_eq!(group(&s), before);
assert!(s.redo());
assert_eq!(group(&s), animated);
let inner = animated.get("innerRadius").unwrap().uid;
s.state.selected_props = vec![(LayerId(lid), inner)];
s.execute("prop.setExpression", json!({"layer": lid, "prop": uid, "expression": ""})).unwrap();
assert!(group(&s).get("innerRadius").is_none());
assert!(s.state.selected_props.is_empty());
assert!(s.undo());
assert_eq!(group(&s), animated);
assert!(s.redo());
assert!(group(&s).get("innerRadius").is_none());
}
}
#[test]
fn polystar_animation_menu_adds_inner_controls_and_undoes() {
let mut s = comp();
let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap();
let group = |s: &Session| layer(s, lid).props.group("contents/group/contents/star").unwrap().clone();
let before = group(&s);
s.state.selected_props = vec![(LayerId(lid), before.get("type").unwrap().uid)];
s.execute("anim.addKeyframe", json!({})).unwrap();
let animated = group(&s);
assert_eq!(animated.get("type").unwrap().keys.len(), 1);
assert!(animated.get("innerRadius").is_some() && animated.get("innerRoundness").is_some());
assert!(s.undo());
assert_eq!(group(&s), before);
assert!(s.redo());
assert_eq!(group(&s), animated);
}
#[test]
fn deleting_the_last_polystar_type_key_removes_stale_controls_and_selection() {
for command in ["keys.delete", "edit.clear"] {
let mut s = comp();
let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap();
let group = |s: &Session| layer(s, lid).props.group("contents/group/contents/star").unwrap().clone();
let type_uid = group(&s).get("type").unwrap().uid;
s.execute("prop.addKey", json!({"layer": lid, "prop": type_uid, "time": 0, "value": 1})).unwrap();
let animated = group(&s);
let inner = animated.get("innerRadius").unwrap().uid;
s.state.selected_props = vec![(LayerId(lid), inner)];
s.state.selected_keys = vec![crate::KeyRef { layer: LayerId(lid), prop: type_uid, time: Tick::ZERO }];
s.execute(command, json!({})).unwrap();
let polygon = group(&s);
assert!(polygon.get("innerRadius").is_none() && polygon.get("innerRoundness").is_none());
assert!(s.state.selected_props.is_empty() && s.state.selected_keys.is_empty());
assert!(s.undo());
assert_eq!(group(&s), animated);
assert!(s.redo());
assert_eq!(group(&s), polygon);
}
}
#[test]
fn polystar_sync_cleans_the_edited_compositions_parked_selection() {
let mut s = comp();
let first = s.active_comp_id().unwrap();
let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap();
let path = "contents/group/contents/star";
let type_uid = layer(&s, lid).props.group(path).unwrap().get("type").unwrap().uid;
s.execute("prop.addKey", json!({"layer": lid, "prop": type_uid, "time": 0, "value": 1})).unwrap();
let before = layer(&s, lid).props.group(path).unwrap().clone();
let inner = before.get("innerRadius").unwrap().uid;
s.state.selected_props = vec![(LayerId(lid), inner)];
s.state.selected_keys = vec![crate::KeyRef { layer: LayerId(lid), prop: inner, time: Tick::ZERO }];
s.execute("comp.new", json!({"width": 100, "height": 100, "duration": 2})).unwrap();
let second = s.active_comp_id().unwrap();
let other = s.execute("layer.newSolid", json!({})).unwrap()["layer"].as_u64().unwrap();
let other_prop = layer(&s, other).props.prop("transform/rotation").unwrap().uid;
s.execute("prop.addKey", json!({"layer": other, "prop": other_prop, "time": 0, "value": 15})).unwrap();
s.state.selected_props = vec![(LayerId(other), other_prop)];
s.state.selected_keys = vec![crate::KeyRef { layer: LayerId(other), prop: other_prop, time: Tick::ZERO }];
let live = (s.state.selected_props.clone(), s.state.selected_keys.clone());
s.execute("prop.toggleAnimation", json!({"comp": first.0, "layer": lid, "prop": type_uid, "value": false})).unwrap();
assert_eq!(s.active_comp_id(), Some(second));
assert_eq!((s.state.selected_props.clone(), s.state.selected_keys.clone()), live);
let parked = s.state.comp_selections.get(&first).unwrap();
assert!(parked.props.is_empty() && parked.keys.is_empty(), "the inactive composition must not keep removed control references");
let after = s.project.comp(first).unwrap().layer(LayerId(lid)).unwrap().props.group(path).unwrap().clone();
assert!(after.get("innerRadius").is_none());
assert!(s.undo());
assert_eq!(s.project.comp(first).unwrap().layer(LayerId(lid)).unwrap().props.group(path).unwrap(), &before);
assert!(s.redo());
assert_eq!(s.project.comp(first).unwrap().layer(LayerId(lid)).unwrap().props.group(path).unwrap(), &after);
}
#[test]
fn polystar_multi_property_expression_clear_resolves_targets_before_removing_controls() {
let mut s = comp();
let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap();
let group = |s: &Session| layer(s, lid).props.group("contents/group/contents/star").unwrap().clone();
let type_uid = group(&s).get("type").unwrap().uid;
s.execute("prop.setExpression", json!({"layer": lid, "prop": type_uid, "expression": "1"})).unwrap();
let inner = group(&s).get("innerRadius").unwrap().uid;
s.execute("prop.setExpression", json!({"layer": lid, "prop": inner, "expression": "37"})).unwrap();
let before = group(&s);
s.state.selected_props = vec![(LayerId(lid), type_uid), (LayerId(lid), inner)];
assert_eq!(s.execute("prop.setExpression", json!({"expression": ""})).unwrap(), json!([null, null]));
let after = group(&s);
assert!(after.get("type").unwrap().expr.is_none());
assert!(after.get("innerRadius").is_none());
assert_eq!(s.state.selected_props, vec![(LayerId(lid), type_uid)]);
assert!(s.undo());
assert_eq!(group(&s), before);
assert!(s.redo());
assert_eq!(group(&s), after);
}
/// Undo must discard removed controls before their UIDs can name newly allocated properties.
#[test]
fn polystar_undo_sanitizes_live_and_parked_property_selections_before_uid_reuse() {
for parked in [false, true] {
let mut s = comp();
let cid = s.active_comp_id().unwrap();
let lid = s.execute("layer.newShape", json!({"kind": "polygon"})).unwrap()["layer"].as_u64().unwrap();
let polygon = layer(&s, lid).props.group("contents/group/contents/star").unwrap().clone();
let type_uid = polygon.get("type").unwrap().uid;
s.execute("comp.new", json!({"width": 100, "height": 100, "duration": 2})).unwrap();
let other = s.active_comp_id().unwrap();
s.activate_comp(cid);
s.state.selected_props = vec![(LayerId(lid), type_uid)];
s.execute("anim.addKeyframe", json!({})).unwrap();
let inner = layer(&s, lid).props.group("contents/group/contents/star").unwrap().get("innerRadius").unwrap().uid;
let valid = vec![(LayerId(lid), type_uid), (LayerId(lid), polygon.uid)];
s.state.selected_props = valid.iter().copied().chain([(LayerId(lid), inner)]).collect();
s.state.selected_keys = vec![crate::KeyRef { layer: LayerId(lid), prop: inner, time: Tick::ZERO }];
if parked {
s.activate_comp(other);
}
assert!(s.undo());
let (props, keys) = if parked {
let selection = s.state.comp_selections.get(&cid).unwrap();
(&selection.props, &selection.keys)
} else {
(&s.state.selected_props, &s.state.selected_keys)
};
assert_eq!(props, &valid, "remove stale leaves while preserving property and group selections");
assert!(keys.is_empty());
s.activate_comp(cid);
s.execute("prop.separateDimensions", json!({"layer": lid, "value": true})).unwrap();
let position_z = layer(&s, lid).props.prop("transform/positionZ").unwrap().clone();
assert_eq!(position_z.uid, inner, "the removed control's UID was actually reused");
s.execute("prop.setExpression", json!({"expression": "1"})).unwrap();
assert_eq!(layer(&s, lid).props.prop("transform/positionZ").unwrap(), &position_z, "the reused UID must not become an implicit expression target");
}
}
+17 -3
View File
@@ -37,14 +37,21 @@ pub fn frame_number(path: &str) -> Option<i64> {
}
/// The After Effects name of a numbered sequence: `shot_[0001-0250].png`. `None` when the first
/// file isn't numbered.
/// file isn't numbered. Files that are only a number (`0001.png`) take their folder's name in
/// front, `plate [0001-0250].png`, so sequences from several folders don't all read the same
/// (#2019).
pub fn sequence_name(files: &[String]) -> Option<String> {
let (first, last) = (files.first()?, files.last()?);
let name = |p: &str| p.rsplit(['/', '\\']).next().unwrap_or(p).to_string();
let mut parts = first.rsplit(['/', '\\']);
let first_name = parts.next().unwrap_or(first);
let folder = parts.next().filter(|f| !f.is_empty() && !f.ends_with(':'));
let (prefix, a) = split_frame_number(stem_of(first))?;
let (_, b) = split_frame_number(stem_of(last))?;
let first_name = name(first);
let ext = first_name.rsplit_once('.').map(|(_, e)| format!(".{e}")).unwrap_or_default();
let prefix = match folder {
Some(f) if prefix.is_empty() => format!("{f} "),
_ => prefix.to_string(),
};
Some(format!("{prefix}[{a}-{b}]{ext}"))
}
@@ -135,6 +142,13 @@ mod tests {
let files: Vec<String> = ["/s/fx_0998.png", "/s/fx_1002.png"].map(String::from).to_vec();
assert_eq!(sequence_name(&files).as_deref(), Some("fx_[0998-1002].png"));
assert_eq!(sequence_name(&["/s/a.png".to_string()]), None);
// #2019: frames that are only a number take their folder's name.
let plain: Vec<String> = ["/shots/plate/0001.png", "/shots/plate/0250.png"].map(String::from).to_vec();
assert_eq!(sequence_name(&plain).as_deref(), Some("plate [0001-0250].png"));
let windows: Vec<String> = ["C:\\shots\\bg\\001.exr", "C:\\shots\\bg\\010.exr"].map(String::from).to_vec();
assert_eq!(sequence_name(&windows).as_deref(), Some("bg [001-010].exr"));
assert_eq!(sequence_name(&["0001.png".to_string(), "0002.png".to_string()]).as_deref(), Some("[0001-0002].png"));
assert_eq!(sequence_name(&["C:\\0001.png".to_string()]).as_deref(), Some("[0001-0001].png"));
}
#[test]
+3 -1
View File
@@ -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) => {
@@ -411,6 +411,8 @@ impl EffectcraftApp {
header::show(self, ui, header);
let body = egui::Rect::from_min_max(egui::pos2(full.min.x + 4.0, header.max.y + 2.0), egui::pos2(full.max.x - 4.0, full.max.y - 4.0));
crate::color_picker::note_dialog(&ctx, self.dialog);
// After the shortcuts, menus and header, which can replace the project (New, Open).
self.ui.timeline.scope_links(self.session.script_ui.project_epoch);
self.dock_area(ui, body);
#[cfg(not(target_arch = "wasm32"))]
prefs_live::dropped_files(self, ui, full);
+1 -1
View File
@@ -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) {
+2 -2
View File
@@ -226,7 +226,7 @@ fn snapping_toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
let snap = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0));
// The engine owns snapping (View ▸ Snapping); the checkbox mirrors it.
app.ui.snapping = app.session.state.snapping;
if widgets::checkbox(ui, snap, app.ui.snapping, &t, egui::Id::unique("snapping")).clicked() {
if widgets::checkbox(ui, snap, app.ui.snapping, &t, egui::Id::unique("snapping")).changed() {
let _ = app.session.execute("view.snapping", serde_json::json!({}));
app.ui.snapping = app.session.state.snapping;
}
@@ -745,7 +745,7 @@ fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
p.text(pos2(x, cy), Align2::LEFT_CENTER, tr("Mesh:"), Tokens::ui(12.0), t.text_dim);
x += 40.0;
let show = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0));
if widgets::checkbox(ui, show, o.show_mesh, &t, egui::Id::unique("puppet-show-mesh")).clicked() {
if widgets::checkbox(ui, show, o.show_mesh, &t, egui::Id::unique("puppet-show-mesh")).changed() {
let _ = app.session.execute("puppet.mesh", json!({"showMesh": !o.show_mesh}));
}
app.auto.add("header.puppet.showMesh", show, "Show mesh");
+4 -4
View File
@@ -1907,7 +1907,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[
("layer.newShape", "Shape Layer", "シェイプレイヤー"),
("layer.newAdjustment", "Adjustment Layer", "調整レイヤー"),
("layer.newContentAwareFill", "Content-Aware Fill Layer...", "コンテンツに応じた塗りつぶしレイヤー..."),
("layer.newPrimitive", "3D Primitive...", "3D プリミティブ..."),
("layer.newPrimitive", "3D Primitive...", "3Dプリミティブ..."),
("layer.new3dPrimitive", "Cube", "立方体"),
("layer.new3dPrimitive", "Sphere", "球体"),
("layer.new3dPrimitive", "Plane", "平面"),
@@ -2206,9 +2206,9 @@ const JAPANESE: &[(&str, &str, &str)] = &[
("view.zoomOut", "Zoom Out", "ズームアウト"),
("", "Resolution", "解像度"),
("view.res.full", "Full", "フル画質"),
("view.res.half", "Half", "1/2 画質"),
("view.res.third", "Third", "1/3 画質"),
("view.res.quarter", "Quarter", "1/4 画質"),
("view.res.half", "Half", "1/2画質"),
("view.res.third", "Third", "1/3画質"),
("view.res.quarter", "Quarter", "1/4画質"),
("view.res.custom", "Custom...", "カスタム..."),
("view.displayColorManagement", "Use Display Color Management", "ディスプレイカラーマネジメントを使用"),
("", "Simulate Output", "出力をシミュレート"),
+33 -33
View File
@@ -396,7 +396,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Lighter", "明るく"),
("Link Selected Property", "選択したプロパティをリンク"),
("Location:", "位置:"),
("Lock Aspect Ratio to {}", "縦横比を {} に固定"),
("Lock Aspect Ratio to {}", "縦横比を{}に固定"),
("Lock to Zoom", "ズームにロック"),
("Locked: {} — {}", "ロック済み:{} — {}"),
("Log:", "ログ:"),
@@ -464,8 +464,8 @@ const JAPANESE: &[(&str, &str)] = &[
("One-Node Camera", "1ノードカメラ"),
("Opacity:", "不透明度:"),
("Open Composition", "コンポジションを開く"),
("used once", "1 回使用"),
("used {} times", "{} 回使用"),
("used once", "1回使用"),
("used {} times", "{}回使用"),
("Compositions that use this item", "このアイテムを使用しているコンポジション"),
("Open Last Saved Project", "最後に保存したプロジェクトを開く"),
("Open New Composition", "新規コンポジションを開く"),
@@ -658,16 +658,16 @@ const JAPANESE: &[(&str, &str)] = &[
("Fit", "フィット"),
("Fit up to 100%", "100%までフィット"),
("Full", "フル画質"),
("Half", "1/2 画質"),
("Half", "1/2画質"),
("Only this layer uses this solid.", "この平面を使用するのはこのレイヤーだけです。"),
("Proportional Grid", "プロポーショナルグリッド"),
("Quarter", "1/4 画質"),
("Quarter", "1/4画質"),
("Region of Interest", "関心領域"),
("Reset Exposure", "露出をリセット"),
("Show Channel and Color Management Settings", "チャンネルとカラーマネジメント設定を表示"),
("Show Snapshot (hold)", "スナップショットを表示(長押し)"),
("Take Snapshot", "スナップショットを取得"),
("Third", "1/3 画質"),
("Third", "1/3画質"),
("Toggle Mask and Shape Path Visibility", "マスクとシェイプのパスを表示"),
("Toggle Transparency Grid", "透明グリッドを切り替え"),
("16 bits per channel", "チャンネルあたり16ビット"),
@@ -681,7 +681,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Draft", "ドラフト"),
("Enable Per-character 3D", "文字ごとの3Dを有効化"),
("Fast Draft", "高速ドラフト"),
("Fast Previews (Draft 3D, Faster Effects)", "高速プレビュー(ドラフト 3D、より高速なエフェクト)"),
("Fast Previews (Draft 3D, Faster Effects)", "高速プレビュー(ドラフト3D、より高速なエフェクト)"),
("Green", "緑"),
("Import Image Sequence", "画像シーケンスを読み込み"),
("Import PDF / Illustrator File", "PDF / Illustratorファイルを読み込み"),
@@ -694,7 +694,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Off (Final Quality)", "オフ(最終品質)"),
("OpenGL (compatibility)", "OpenGL(互換)"),
("Opus Bitrate: {} kbps", "Opusビットレート:{} kbps"),
("RGB Straight", "RGB ストレート"),
("RGB Straight", "RGBストレート"),
("Red", "赤"),
("Tone map", "トーンマップ"),
("Use In-Window Menu Bar on macOS", "macOSでウィンドウ内メニューバーを使用"),
@@ -794,10 +794,10 @@ const JAPANESE: &[(&str, &str)] = &[
("Faster", "高速"),
("More Accurate", "精度を優先"),
("Display Color Space", "表示色空間"),
("GPU Information...", "GPU 情報..."),
("GPU Information...", "GPU情報..."),
("Appearance", "外観"),
("Brightness", "明るさ"),
("UI Scale", "UI スケール"),
("UI Scale", "UIスケール"),
("Labels and Colors", "ラベルとカラー"),
("Use Label Color for Layer Handles and Paths", "レイヤーハンドルとパスにラベルカラーを使用"),
("Use Label Color for Related Tabs", "ラベルカラーを関連するタブに使用"),
@@ -864,8 +864,8 @@ const JAPANESE: &[(&str, &str)] = &[
("Media Cache", "メディアキャッシュ"),
("Database and Cache Folder", "データベースとキャッシュのフォルダー"),
("Conformed Audio Folder", "適合オーディオフォルダー"),
("Memory & CPU", "メモリと CPU"),
("RAM Reserved for Other Applications", "他のアプリケーション用に確保する RAM"),
("Memory & CPU", "メモリとCPU"),
("RAM Reserved for Other Applications", "他のアプリケーション用に確保するRAM"),
("Cache Budgets", "キャッシュの割り当て"),
("Layer Cache", "レイヤーキャッシュ"),
("Footage Frame Cache", "フッテージフレームキャッシュ"),
@@ -879,7 +879,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Video Output During Playback", "再生中のビデオ出力"),
("Mirror on Computer Monitor", "コンピューターモニターにミラーリング"),
("Disable Video Output When in Background", "バックグラウンド動作時のビデオ出力を無効にする"),
("Default 3D Renderer", "既定の 3D レンダラー"),
("Default 3D Renderer", "既定の3Dレンダラー"),
("Show 3D Reference Axes", "3D参照軸を表示"),
("Extended Viewer", "拡張ビューアー"),
("Realtime Shadows in Draft", "ドラフトのリアルタイムシャドウ"),
@@ -925,11 +925,11 @@ const JAPANESE: &[(&str, &str)] = &[
("Speed (%)", "速度 (%)"),
("Use Draft Deformation", "下書き変形を使用"),
("Show Mesh", "メッシュを表示"),
("Scale X (%)", "X 方向の拡大率 (%)"),
("Scale Y (%)", "Y 方向の拡大率 (%)"),
("Scale X (%)", "X方向の拡大率 (%)"),
("Scale Y (%)", "Y方向の拡大率 (%)"),
("Rotation (degrees)", "回転 (度)"),
("Move X", "X 移動"),
("Move Y", "Y 移動"),
("Move X", "X移動"),
("Move Y", "Y移動"),
("Position (pixels)", "位置 (ピクセル)"),
("Position (percent of the composition)", "位置 (コンポジションに対する割合)"),
("Use Dimensions From", "使用するサイズの基準"),
@@ -944,7 +944,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Pixel Aspect Ratio", "ピクセル縦横比"),
("Loop (times)", "ループ (回)"),
("Assign Profile", "プロファイルを割り当て"),
("Preserve RGB", "RGB を保持"),
("Preserve RGB", "RGBを保持"),
("Interpret As Linear Light", "リニア光として解釈"),
("Start Timecode", "開始タイムコード"),
("Override Start", "開始値を上書き"),
@@ -953,7 +953,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Working space", "作業用スペース"),
("Working gamma", "作業用ガンマ"),
("Linearize working space", "作業用スペースをリニア化"),
("Blend colors using 1.0 gamma", "1.0 ガンマで色を混合"),
("Blend colors using 1.0 gamma", "1.0ガンマで色を混合"),
("Output color space", "出力カラースペース"),
("Video rendering and effects", "ビデオのレンダリングとエフェクト"),
("Time Span", "時間範囲"),
@@ -965,7 +965,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Apply To New Layer", "新しいレイヤーに適用"),
("Sensitivity threshold (0–1, lower finds more)", "感度のしきい値 (0~1、低いほど多く検出)"),
("Time field (empty: automatic)", "時間フィールド (空欄: 自動)"),
("Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", "ビデオ開始 (ISO 日時、hh:mm:ss、タイムコード、空欄: ファイルの日付)"),
("Video start (ISO date-time, hh:mm:ss, timecode; empty: file date)", "ビデオ開始 (ISO日時、hh:mm:ss、タイムコード、空欄: ファイルの日付)"),
("First sample at (comp seconds)", "最初のサンプル位置 (コンポジション秒)"),
("Frame rate", "フレームレート"),
("Color (#rrggbb)", "カラー (#rrggbb)"),
@@ -1058,12 +1058,12 @@ const JAPANESE: &[(&str, &str)] = &[
("Mask Interpolation", "マスク補間"),
("Script Console", "スクリプトコンソール"),
("Essential Graphics", "エッセンシャルグラフィックス"),
("ScriptUI Panel", "ScriptUI パネル"),
("Lumetri Scopes", "Lumetri スコープ"),
("ScriptUI Panel", "ScriptUIパネル"),
("Lumetri Scopes", "Lumetriスコープ"),
("Media Browser", "メディアブラウザー"),
("Content-Aware Fill", "コンテンツに応じた塗りつぶし"),
("Create Nulls From Paths", "パスからヌルを作成"),
("VR Comp Editor", "VR コンポジションエディター"),
("VR Comp Editor", "VRコンポジションエディター"),
("Media Duration", "メディアの長さ"),
("Frame Rate", "フレームレート"),
("File Path", "ファイルパス"),
@@ -1075,7 +1075,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Next Frame", "次のフレーム"),
("Last Frame", "最後のフレーム"),
("Name (A–Z)", "名前 (A–Z)"),
("Merged PRs", "マージ済み PR"),
("Merged PRs", "マージ済みPR"),
("Lines added", "追加行数"),
("Lines deleted", "削除行数"),
("Line delta", "行数の差分"),
@@ -1085,9 +1085,9 @@ const JAPANESE: &[(&str, &str)] = &[
("Last commit", "最後のコミット"),
("Display name", "表示名"),
("Real name", "本名"),
("ArtCraft website", "ArtCraft のウェブサイト"),
("Source code on GitHub", "GitHub のソースコード"),
("3D Renderer", "3D レンダラー"),
("ArtCraft website", "ArtCraftのウェブサイト"),
("Source code on GitHub", "GitHubのソースコード"),
("3D Renderer", "3Dレンダラー"),
("Preset Name", "プリセット名"),
("Save Preset…", "プリセットを保存…"),
("Delete Preset", "プリセットを削除"),
@@ -1099,9 +1099,9 @@ const JAPANESE: &[(&str, &str)] = &[
("Shutter Phase", "シャッターフェーズ"),
("Samples Per Frame", "フレームあたりのサンプル数"),
("Adaptive Sample Limit", "適応サンプル数の上限"),
("X and Y", "X と Y"),
("X only", "X のみ"),
("Y only", "Y のみ"),
("X and Y", "XとY"),
("X only", "Xのみ"),
("Y only", "Yのみ"),
("Track Camera", "カメラをトラッキング"),
("Warp Stabilizer", "ワープスタビライザー"),
("Track Motion", "モーションをトラッキング"),
@@ -1177,8 +1177,8 @@ const JAPANESE: &[(&str, &str)] = &[
("Show Animation Presets", "アニメーションプリセットを表示"),
(" Refresh List", " リストを更新"),
(" Clear Recently Used", " 最近使用した項目を消去"),
("Show 32 bpc Effects Only", "32 bpc のエフェクトのみ表示"),
("Show GPU Accelerated Effects Only", "GPU アクセラレーションのエフェクトのみ表示"),
("Show 32 bpc Effects Only", "32 bpcのエフェクトのみ表示"),
("Show GPU Accelerated Effects Only", "GPUアクセラレーションのエフェクトのみ表示"),
("Show Effects for All Color Depths", "すべての色深度のエフェクトを表示"),
("* Animation Presets", "* アニメーションプリセット"),
("Select a layer to apply to", "適用するレイヤーを選択してください"),
@@ -1207,7 +1207,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Adjust Exposure", "露出を調整"),
("Snapshot taken", "スナップショットを撮影しました"),
("Fast Previews", "高速プレビュー"),
("3D View", "3D ビュー"),
("3D View", "3Dビュー"),
("Current time", "現在の時間"),
("Layer Transform", "レイヤーのトランスフォーム"),
("Reset Transform", "トランスフォームをリセット"),
+7
View File
@@ -112,6 +112,8 @@ pub enum Icon {
Fullscreen,
Channel,
Exposure,
/// Stroke width: three bars, thin to thick.
StrokeWidth,
// links
Chat,
Globe,
@@ -548,6 +550,11 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
}
pen.circle(8.0, 8.0, 4.2);
}
StrokeWidth => {
pen.rect_fill(2.5, 3.0, 13.5, 3.75);
pen.rect_fill(2.5, 6.5, 13.5, 8.0);
pen.rect_fill(2.5, 10.5, 13.5, 13.0);
}
Hamburger => {
for y in [4.5, 8.0, 11.5] {
pen.line(&[(3.0, y), (13.0, y)]);
+3
View File
@@ -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;
}
+16 -1
View File
@@ -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(&params) && 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.
+2 -2
View File
@@ -136,8 +136,8 @@ pub fn chord_matches(session: &effectcraft_engine::Session, command: &str, key:
bindings(session).into_iter().any(|(m, k, id, _)| id == command && k == key && mods_match(m, mods))
}
/// Whether a Deselect All chord matched by [`chord_matches`] may run while a text editor holds
/// the keyboard. Chords carrying Cmd/Ctrl/Alt are command chords (the editor treats them as
/// Whether a Deselect All or Duplicate chord matched by [`chord_matches`] may run while a text
/// editor holds the keyboard. Chords carrying Cmd/Ctrl/Alt are command chords (the editor treats them as
/// such for undo and the rest), and function keys never type, so those forward from the editor;
/// a chord that could instead produce a character — a bare or Shift-only letter, digit or
/// punctuation key, as from a pathological bare-letter rebinding — stays with the editor so
+169
View File
@@ -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"));
+1 -1
View File
@@ -45,7 +45,7 @@ fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &st
fn checkbox(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, text: &str, on: bool) -> bool {
let t = app.tokens;
let r = Rect::from_min_size(at, vec2(16.0, 16.0));
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::unique(("animtool-cb", auto))).clicked();
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::unique(("animtool-cb", auto))).changed();
ui.painter().text(pos2(r.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text);
app.auto.add(auto, r, text);
if clicked { !on } else { on }
+3 -3
View File
@@ -310,7 +310,7 @@ fn tab_body(
let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.width).range(4..=30000).suffix(" px")));
auto.add("dialog.comp.width", r.rect, "Width");
let lbl = tr_args("Lock Aspect Ratio to {}", &[&(aspect_label(ow as f64, oh as f64))]);
let r = ui.checkbox(&mut d.lock_aspect, lbl);
let r = crate::widgets::labelled_checkbox(ui, &mut d.lock_aspect, lbl);
auto.add("dialog.comp.lockAspect", r.rect, "Lock Aspect Ratio");
if d.lock_aspect && ow != d.width && ow > 0 {
d.height = ((d.width as f64) * oh as f64 / ow as f64).round().max(4.0) as u32;
@@ -426,11 +426,11 @@ fn tab_body(
});
ui.end_row();
ui.label("");
let r = ui.checkbox(&mut d.preserve_frame_rate, tr("Preserve frame rate when nested or in render queue"));
let r = crate::widgets::labelled_checkbox(ui, &mut d.preserve_frame_rate, tr("Preserve frame rate when nested or in render queue"));
auto.add("dialog.comp.preserveFrameRate", r.rect, "Preserve frame rate when nested or in render queue");
ui.end_row();
ui.label("");
let r = ui.checkbox(&mut d.preserve_resolution, tr("Preserve resolution when nested"));
let r = crate::widgets::labelled_checkbox(ui, &mut d.preserve_resolution, tr("Preserve resolution when nested"));
auto.add("dialog.comp.preserveResolution", r.rect, "Preserve resolution when nested");
ui.end_row();
ui.label(egui::RichText::new(tr("Motion Blur")).strong());
+6 -2
View File
@@ -584,7 +584,11 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
ui.horizontal(|ui| {
let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut size[0]).range(1..=30000).suffix(" px")));
auto.add("dialog.solid.width", r.rect, "Width");
let r = ui.checkbox(&mut lock, tr_args("Lock Aspect Ratio to {}", &[&(super::comp_settings::aspect_label(ow as f64, oh as f64))]));
let r = crate::widgets::labelled_checkbox(
ui,
&mut lock,
tr_args("Lock Aspect Ratio to {}", &[&(super::comp_settings::aspect_label(ow as f64, oh as f64))]),
);
auto.add("dialog.solid.lockAspect", r.rect, "Lock Aspect Ratio");
});
ui.end_row();
@@ -640,7 +644,7 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
if editing.is_some() {
ui.add_space(8.0);
let r = ui
.add_enabled(shared, egui::Checkbox::new(&mut affect_all, tr("Affect all layers that use this solid")))
.add_enabled(shared, crate::widgets::DragCheckbox::new(&mut affect_all, tr("Affect all layers that use this solid")))
.on_disabled_hover_text(tr("Only this layer uses this solid."));
auto.add("dialog.solid.affectAll", r.rect, "Affect all layers that use this solid");
if shared && !affect_all {
+3 -3
View File
@@ -231,12 +231,12 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
});
ui.end_row();
ui.label(tr("Depth of Field"));
ui.checkbox(&mut d.dof, tr("Enable Depth of Field"));
crate::widgets::labelled_checkbox(ui, &mut d.dof, tr("Enable Depth of Field"));
ui.end_row();
ui.label(tr("Focus Distance"));
ui.horizontal(|ui| {
ui.add_enabled(!d.lock_to_zoom, crate::widgets::drag_value(egui::DragValue::new(&mut d.focus).range(0.0..=100000.0).suffix(" px")));
ui.checkbox(&mut d.lock_to_zoom, tr("Lock to Zoom"));
crate::widgets::labelled_checkbox(ui, &mut d.lock_to_zoom, tr("Lock to Zoom"));
});
ui.end_row();
ui.label(tr("Aperture"));
@@ -354,7 +354,7 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
}
if casts_shadows(d.kind) {
ui.label(tr("Shadows"));
let cb = ui.checkbox(&mut d.casts_shadows, tr("Casts Shadows"));
let cb = crate::widgets::labelled_checkbox(ui, &mut d.casts_shadows, tr("Casts Shadows"));
app.auto.add_response("dialog.light.casts", &cb, "Casts Shadows");
ui.end_row();
ui.label(tr("Shadow Darkness"));
@@ -367,7 +367,7 @@ fn value_toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value:
};
let PropCx { t, uid, cy, vx, p, prop, .. } = *cx;
let cr = Rect::from_min_size(pos2(vx, cy - 8.0), vec2(16.0, 16.0));
if widgets::checkbox(ui, cr, *b, &t, egui::Id::unique(("ec-b", uid))).clicked() {
if widgets::checkbox(ui, cr, *b, &t, egui::Id::unique(("ec-b", uid))).changed() {
cx.set(actions, json!(!b));
}
p.text(pos2(cr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text_dim);
+1 -1
View File
@@ -362,7 +362,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt
}
ControlType::Checkbox => {
let mut on = v.as_bool();
let r = ui.checkbox(&mut on, "");
let r = crate::widgets::labelled_checkbox(ui, &mut on, "");
if r.changed() {
out = Some(json!(on));
}
+8 -8
View File
@@ -888,7 +888,7 @@ fn form_grid(ui: &mut egui::Ui, f: &mut Form, regs: &mut Vec<(String, egui::Rect
.inner
.rect
}
FieldKind::Bool(b) => ui.checkbox(b, "").rect,
FieldKind::Bool(b) => crate::widgets::labelled_checkbox(ui, b, "").rect,
FieldKind::Choice { options, sel } => {
let cur = options.get(*sel).map(|o| o.0.clone()).unwrap_or_default();
// The column keeps its width: a long choice (a GPU adapter's name) ends in an
@@ -1018,13 +1018,13 @@ pub fn show_view_options(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Toke
let mut close = false;
super::dialogs::modal(ctx, "View Options", vec2(360.0, 330.0), t, |ui| {
let v = &mut app.ui.viewer;
ui.checkbox(&mut v.show_layer_controls, tr("Layer controls"));
ui.checkbox(&mut v.show_masks, tr("Masks"));
ui.checkbox(&mut v.rulers, tr("Rulers"));
ui.checkbox(&mut v.guides, tr("Guides"));
ui.checkbox(&mut v.grid, tr("Grid"));
ui.checkbox(&mut v.safe_margins, tr("Title/action safe"));
ui.checkbox(&mut v.transparency_grid, tr("Transparency grid"));
crate::widgets::labelled_checkbox(ui, &mut v.show_layer_controls, tr("Layer controls"));
crate::widgets::labelled_checkbox(ui, &mut v.show_masks, tr("Masks"));
crate::widgets::labelled_checkbox(ui, &mut v.rulers, tr("Rulers"));
crate::widgets::labelled_checkbox(ui, &mut v.guides, tr("Guides"));
crate::widgets::labelled_checkbox(ui, &mut v.grid, tr("Grid"));
crate::widgets::labelled_checkbox(ui, &mut v.safe_margins, tr("Title/action safe"));
crate::widgets::labelled_checkbox(ui, &mut v.transparency_grid, tr("Transparency grid"));
ui.add_space(12.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.add(egui::Button::new(egui::RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent)).clicked() {
+14 -7
View File
@@ -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));
}
}
+1 -1
View File
@@ -168,7 +168,7 @@ pub fn velocity(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
});
ui.add_space(8.0);
}
let r = ui.checkbox(&mut d.continuous, tr("Continuous (Lock Outgoing to Incoming)"));
let r = crate::widgets::labelled_checkbox(ui, &mut d.continuous, tr("Continuous (Lock Outgoing to Incoming)"));
app.auto.add("dialog.velocity.continuous", r.rect, "Continuous");
if d.continuous {
d.out_speed = d.in_speed.clone();
@@ -89,7 +89,7 @@ fn control(ui: &mut egui::Ui, p: &Property, v: &KV) -> Option<Value> {
match (v, &p.ui) {
(KV::Bool(b), _) => {
let mut on = *b;
ui.checkbox(&mut on, "").changed().then(|| json!(on))
crate::widgets::labelled_checkbox(ui, &mut on, "").changed().then(|| json!(on))
}
(KV::Enum(i), ParamUi::Popup { options }) => {
let mut sel = *i as usize;
@@ -177,7 +177,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
if id != BLENDING {
let g = styles.as_ref().and_then(|s| s.sub(id));
let mut on = g.is_some_and(|g| g.enabled);
if ui.checkbox(&mut on, "").changed() {
if crate::widgets::labelled_checkbox(ui, &mut on, "").changed() {
if g.is_none() {
actions.push(("layer.style.add".into(), json!({"layers": [lid], "style": id})));
} else {
+2 -2
View File
@@ -155,7 +155,7 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
ui.end_row();
});
ui.add_space(8.0);
let r = ui.checkbox(&mut d.cue, tr("Cue Point"));
let r = crate::widgets::labelled_checkbox(ui, &mut d.cue, tr("Cue Point"));
app.auto.add("dialog.marker.cue", r.rect, "Cue Point");
ui.add_enabled_ui(d.cue, |ui| {
ui.horizontal(|ui| {
@@ -191,7 +191,7 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
});
ui.add_space(8.0);
ui.horizontal(|ui| {
let r = ui.checkbox(&mut d.protected, tr("Protected Region"));
let r = crate::widgets::labelled_checkbox(ui, &mut d.protected, tr("Protected Region"));
app.auto.add("dialog.marker.protected", r.rect, "Protected Region");
ui.add_space(20.0);
ui.label(tr("Label:"));
+1 -1
View File
@@ -264,7 +264,7 @@ fn fmt_rate(r: f64) -> String {
fn check_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, at: egui::Pos2, key: &str, text: &str, on: bool) -> bool {
let t = app.tokens;
let r = Rect::from_min_size(pos2(at.x, at.y - 8.0), vec2(16.0, 16.0));
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::unique(("pv-check", key))).clicked();
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::unique(("pv-check", key))).changed();
p.text(pos2(r.max.x + 5.0, at.y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text);
app.auto.add(&format!("preview.{key}"), r, text);
clicked
+3 -3
View File
@@ -239,7 +239,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
.enumerate()
{
let r = Rect::from_min_size(pos2(x0 + i as f32 * (w / 2.0), y), vec2(18.0, 20.0));
if widgets::checkbox(ui, r, on, &t, egui::Id::unique(key)).clicked() {
if widgets::checkbox(ui, r, on, &t, egui::Id::unique(key)).changed() {
set(app, json!({k: !on}));
}
p.text(pos2(r.max.x + 4.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim);
@@ -273,7 +273,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
y += 26.0;
let r = Rect::from_min_size(pos2(x0, y), vec2(18.0, 20.0));
if widgets::checkbox(ui, r, o.show_overlay, &t, egui::Id::unique("paint.showOverlay")).clicked() {
if widgets::checkbox(ui, r, o.show_overlay, &t, egui::Id::unique("paint.showOverlay")).changed() {
set(app, json!({"showOverlay": !o.show_overlay}));
}
app.auto.add("paint.showOverlay", r, "Clone Source Overlay");
@@ -282,7 +282,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
set(app, json!({"overlayOpacity": nv}));
}
let dr = Rect::from_min_size(pos2(x0 + w - 18.0, y), vec2(18.0, 20.0));
if widgets::checkbox(ui, dr, o.overlay_difference, &t, egui::Id::unique("paint.overlayDifference")).clicked() {
if widgets::checkbox(ui, dr, o.overlay_difference, &t, egui::Id::unique("paint.overlayDifference")).changed() {
set(app, json!({"overlayDifference": !o.overlay_difference}));
}
app.auto.add("paint.overlayDifference", dr, "Difference mode overlay");
+1 -1
View File
@@ -23,7 +23,7 @@ pub fn dropdown(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, auto: &str
pub fn checkbox(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, text: &str, on: bool) -> bool {
let t = app.tokens;
let r = Rect::from_min_size(at, vec2(16.0, 16.0));
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::unique(("kit-cb", auto))).clicked();
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::unique(("kit-cb", auto))).changed();
ui.painter().text(pos2(r.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text);
let full = Rect::from_min_size(at, vec2(22.0 + text.len() as f32 * 6.5, 16.0));
app.auto.add(auto, full, text);
+1 -1
View File
@@ -54,7 +54,7 @@ pub fn create_nulls(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
acts.push((id, if id == "paths.tracePath" { json!({"loop": looping}) } else { json!({}) }));
}
}
let r = ui.checkbox(&mut looping, tr("Loop (Trace Path)"));
let r = crate::widgets::labelled_checkbox(&mut ui, &mut looping, tr("Loop (Trace Path)"));
app.auto.add("createNulls.loop", r.rect, "Loop");
ui.data_mut(|d| d.insert_temp(loop_id, looping));
run(app, &ctx, acts);
+2 -2
View File
@@ -141,11 +141,11 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
);
ui.add_space(4.0);
ui.add_enabled_ui(!d.leave, |ui| {
let r = ui.checkbox(&mut d.adjust, tr("Adjust composition duration to the time span of the selected layers"));
let r = crate::widgets::labelled_checkbox(ui, &mut d.adjust, tr("Adjust composition duration to the time span of the selected layers"));
app.auto.add("dialog.precompose.adjust", r.rect, "Adjust composition duration");
});
ui.add_space(8.0);
let r = ui.checkbox(&mut d.open, tr("Open New Composition"));
let r = crate::widgets::labelled_checkbox(ui, &mut d.open, tr("Open New Composition"));
app.auto.add("dialog.precompose.open", r.rect, "Open New Composition");
ui.add_space(14.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+1 -1
View File
@@ -1346,7 +1346,7 @@ fn column_menu(app: &mut EffectcraftApp, resp: &egui::Response) {
ui.label(egui::RichText::new(crate::i18n::tr("Columns")).weak());
for (key, label, _) in COLUMNS {
let mut on = app.ui.project_columns.iter().any(|c| c == key);
if ui.checkbox(&mut on, crate::i18n::tr(label)).changed() {
if crate::widgets::labelled_checkbox(ui, &mut on, crate::i18n::tr(label)).changed() {
set_column(&mut app.ui.project_columns, key, on);
}
}
+1 -1
View File
@@ -456,7 +456,7 @@ fn text_section(
for (i, (key, label, on, c)) in [("fill", "Fill", doc.apply_fill, doc.fill), ("stroke", "Stroke", doc.apply_stroke, doc.stroke)].into_iter().enumerate() {
let ry = y + i as f32 * 24.0;
let cr = Rect::from_min_size(pos2(x0, ry + 2.0), vec2(14.0, 14.0));
if widgets::checkbox(ui, cr, on, &t, egui::Id::unique(("props-apply", key))).clicked() {
if widgets::checkbox(ui, cr, on, &t, egui::Id::unique(("props-apply", key))).changed() {
let k = if key == "fill" { "applyFill" } else { "applyStroke" };
set(actions, json!({k: !on}));
}
+2 -2
View File
@@ -501,7 +501,7 @@ fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::P
// Notify (sound + notification when the queue finishes).
let notify = app.session.project.render_prefs.notify;
let nb = Rect::from_min_size(pos2(render_b.min.x - 8.0 - 2.0 * bw - 8.0, render_b.min.y + 4.0), vec2(18.0, 18.0));
if widgets::checkbox(ui, nb, notify, &t, egui::Id::unique("rq-notify")).clicked() {
if widgets::checkbox(ui, nb, notify, &t, egui::Id::unique("rq-notify")).changed() {
actions.push(("renderQueue.setNotify", json!({"notify": !notify})));
}
p.text(pos2(nb.max.x + 5.0, nb.center().y), Align2::LEFT_CENTER, tr("Notify"), Tokens::ui(11.5), t.text_dim);
@@ -797,7 +797,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
app.auto.add(&aid("twirl"), tw, "Twirl");
let cb = Rect::from_center_size(pos2(cx_render + 20.0, row.center().y), vec2(18.0, 18.0));
if widgets::checkbox(ui, cb, it.render, &t, egui::Id::unique(("rq-cb", it.id))).clicked() && !rendering {
if ui.add_enabled_ui(!rendering, |ui| widgets::checkbox(ui, cb, it.render, &t, egui::Id::unique(("rq-cb", it.id)))).inner.changed() && !rendering {
actions.push(("renderQueue.setRender", json!({"item": it.id, "render": !it.render})));
}
app.auto.add(&aid("render"), cb, if it.render { "Render: on" } else { "Render: off" });
+1 -1
View File
@@ -106,7 +106,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
match kind {
Kind::Bool => {
let mut b = v.as_bool().unwrap_or(false);
let r = ui.checkbox(&mut b, tr(label));
let r = crate::widgets::labelled_checkbox(ui, &mut b, tr(label));
if r.changed() {
acts.push(Act::Set(key.into(), json!(b)));
}
+44 -21
View File
@@ -127,16 +127,21 @@ fn numeric_fields(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
let row = i / 2;
let fx = x0 + col as f32 * (w / 2.0);
let fy = y + row as f32 * 28.0;
p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
let (r, nv, _) = widgets::hot_number_at(
ui,
pos2(fx + 26.0, fy),
egui::Id::unique(("char", key)),
v,
&widgets::HotSpec { speed: 0.5, range, decimals: 0, suffix },
&t,
);
let stroke = key == "strokeWidth";
if stroke {
// The stroke-width icon, as in After Effects, not a letter that reads as "width".
crate::icons::paint(p, Rect::from_min_size(pos2(fx, fy + 1.0), vec2(18.0, 18.0)), crate::icons::Icon::StrokeWidth, t.text_dim);
} else {
p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
}
// Fractional stroke widths (the 0.25 / 0.5 / 0.75 presets) show their decimals.
let decimals = if stroke && v.fract().abs() > 1e-9 { 2 } else { 0 };
let (r, nv, _) =
widgets::hot_number_at(ui, pos2(fx + 26.0, fy), egui::Id::unique(("char", key)), v, &widgets::HotSpec { speed: 0.5, range, decimals, suffix }, &t);
app.auto.add(&format!("character.{key}"), r, key);
if stroke {
stroke_width_presets(app, ui, r, enabled, actions);
}
if let Some(nv) = nv
&& enabled
{
@@ -145,6 +150,26 @@ fn numeric_fields(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
}
}
/// The stroke width's presets menu beside its value (`r`), as in After Effects.
fn stroke_width_presets(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, enabled: bool, actions: &mut Vec<serde_json::Value>) {
const PRESETS: [f64; 22] = [0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0];
let t = app.tokens;
let br = Rect::from_min_size(pos2(r.max.x + 4.0, r.min.y + 2.0), vec2(16.0, 16.0));
let pop = egui::Id::unique("char-stroke-width-pop");
if widgets::icon_button(ui, br, crate::icons::Icon::ChevronDown, false, &t, egui::Id::unique("char-stroke-width-presets"))
.on_hover_text(tr("Stroke width presets"))
.clicked()
&& enabled
{
widgets::open_popup(ui, pop);
}
app.auto.add("character.strokeWidth.presets", br, "Stroke width presets");
let labels: Vec<String> = PRESETS.iter().map(|v| format!("{v} px")).collect();
if let Some(v) = widgets::popup_menu(ui, pop, br.left_bottom(), &labels, None).and_then(|i| PRESETS.get(i)) {
actions.push(json!({"strokeWidth": v}));
}
}
/// Kerning: Metrics / Optical / a manual value (grid row 1, left).
fn kerning_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, g: Grid, doc: &TextDoc, actions: &mut Vec<serde_json::Value>) {
let t = app.tokens;
@@ -297,7 +322,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
for (i, (key, label, on, c)) in [("fill", "Fill", doc.apply_fill, doc.fill), ("stroke", "Stroke", doc.apply_stroke, doc.stroke)].into_iter().enumerate() {
let fx = x0 + i as f32 * (w / 2.0);
let cr = Rect::from_min_size(pos2(fx, y + 3.0), vec2(14.0, 14.0));
if widgets::checkbox(ui, cr, on, &t, egui::Id::unique(("char-apply", key))).clicked() && enabled {
if ui.add_enabled_ui(enabled, |ui| widgets::checkbox(ui, cr, on, &t, egui::Id::unique(("char-apply", key)))).inner.changed() && enabled {
actions.push(json!({if key == "fill" { "applyFill" } else { "applyStroke" }: !on}));
}
app.auto.add(&format!("character.{key}.enabled"), cr, label);
@@ -350,7 +375,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
y += 32.0;
// Ligatures.
let lr = Rect::from_min_size(pos2(x0, y), vec2(14.0, 14.0));
if widgets::checkbox(ui, lr, doc.ligatures, &t, egui::Id::unique("char-ligatures")).clicked() && enabled {
if ui.add_enabled_ui(enabled, |ui| widgets::checkbox(ui, lr, doc.ligatures, &t, egui::Id::unique("char-ligatures"))).inner.changed() && enabled {
actions.push(json!({"ligatures": !doc.ligatures}));
}
app.auto.add("character.ligatures", lr, "Ligatures");
@@ -374,7 +399,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
.enumerate()
{
let cr = Rect::from_min_size(pos2(x0 + i as f32 * 120.0, y), vec2(14.0, 14.0));
if widgets::checkbox(ui, cr, on, &t, egui::Id::unique(("char-vert", key))).clicked() && enabled {
if ui.add_enabled_ui(enabled, |ui| widgets::checkbox(ui, cr, on, &t, egui::Id::unique(("char-vert", key)))).inner.changed() && enabled {
actions.push(json!({key: !on}));
}
app.auto.add(&format!("character.{key}"), cr, label);
@@ -677,7 +702,7 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
y += 30.0;
let hr = Rect::from_min_size(pos2(x0, y), vec2(14.0, 14.0));
if widgets::checkbox(ui, hr, doc.hanging_punctuation, &t, egui::Id::unique("para-hanging")).clicked() && enabled {
if ui.add_enabled_ui(enabled, |ui| widgets::checkbox(ui, hr, doc.hanging_punctuation, &t, egui::Id::unique("para-hanging"))).inner.changed() && enabled {
actions.push(json!({"hangingPunctuation": !doc.hanging_punctuation}));
}
app.auto.add("paragraph.hangingPunctuation", hr, "Roman Hanging Punctuation");
@@ -777,11 +802,7 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui:
ui.set_width(rect.width().max(180.0));
// Filter row: a checkbox that keeps only the variable fonts.
let filter_h = 22.0;
let row = ui.add_sized([ui.available_width(), filter_h], egui::Button::selectable(false, ""));
let box_rect = Rect::from_min_size(row.rect.min, vec2(18.0, filter_h));
let label_rect = Rect::from_min_max(pos2(box_rect.max.x + 2.0, row.rect.min.y), row.rect.max);
let boxed = widgets::checkbox(ui, box_rect, only_variable, &t, id.with("variable-check"));
ui.painter().text(label_rect.left_center(), Align2::LEFT_CENTER, tr("Variable fonts only"), Tokens::ui(11.5), t.text);
let row = ui.add_sized([ui.available_width(), filter_h], widgets::DragCheckbox::new(&mut only_variable, tr("Variable fonts only")));
app_ref.auto.add(&format!("{auto}.variable"), row.rect, "Variable fonts only");
// #1089: fonts installed after launch appear without restarting. The button is its own
// row: the popup is only 180 px wide, so a control pinned to the filter row covers
@@ -792,11 +813,13 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui:
app_ref.ui.status = format!("{}…", tr("Rescan Fonts"));
}
app_ref.auto.add(&format!("{auto}.rescan"), resc, "Rescan Fonts");
let toggled = row.clicked() || boxed.clicked();
let toggled = row.changed();
if toggled {
only_variable = !only_variable;
ui.data_mut(|d| d.insert_temp(variable_id, only_variable));
// Typing goes on into the search field.
}
// The native checkbox can take focus on release after changing on press.
// Typing goes on into the search field after either event.
if toggled || row.clicked() {
ui.memory_mut(|m| m.request_focus(search_id));
}
let query = state.query.trim().to_lowercase();
+93 -63
View File
@@ -3058,7 +3058,7 @@ fn layer_mode_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc:
}
let tr = Rect::from_center_size(pos2(cw.mode + 76.0 + 6.0, cy), vec2(14.0, 14.0));
app.auto.add(&format!("timeline.layer.{}.preserveTransparency", layer.id.0), tr, "Preserve Transparency");
if widgets::checkbox(ui, tr, layer.preserve_transparency, &t, egui::Id::unique(("pt", layer.id.0))).clicked() {
if widgets::checkbox(ui, tr, layer.preserve_transparency, &t, egui::Id::unique(("pt", layer.id.0))).changed() {
// The click gives the selection the state the clicked row toggles to,
// whatever the first selected layer's value (as the switches do).
actions.push((
@@ -3565,7 +3565,7 @@ fn group_mask_mode(ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
actions.push(("layer.setMask".into(), json!({"layer": layer.id.0, "mask": uid, "mode": opts[i]})));
}
let ir = Rect::from_min_size(pos2(mr.max.x + 8.0, cy - 8.0), vec2(16.0, 16.0));
if widgets::checkbox(ui, ir, inverted, &t, egui::Id::unique(("minv", uid))).clicked() {
if widgets::checkbox(ui, ir, inverted, &t, egui::Id::unique(("minv", uid))).changed() {
actions.push(("layer.setMask".into(), json!({"layer": layer.id.0, "mask": uid, "inverted": !inverted})));
}
lp.text(pos2(ir.max.x + 4.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Inverted"), Tokens::ui(11.0), t.text_dim);
@@ -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, &params, 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).
@@ -5664,18 +5683,21 @@ fn vector_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>
// Mask Feather: two linked values, never negative (as in After Effects, #203).
let feather = prop.match_id == "feather" && layer.props.parent_of(uid).is_some_and(|g| matches!(g.kind, GroupKind::Mask { .. }));
let range = if feather { (0.0, 1e9) } else { (-1e9, 1e9) };
// Audio Levels link their two channels too, off by default, by the same dB change (#2485).
let levels = prop.match_id == "levels" && layer.props.parent_of(uid).is_some_and(|g| g.match_id == "audio");
// Scale and Mask Feather: the chain link (Constrain Proportions, on by default).
// Rectangle and Ellipse Path Size have it too, off by default (#2489).
let size = prop.match_id == "size" && layer.props.parent_of(uid).is_some_and(|g| matches!(g.match_id.as_str(), "rect" | "ellipse"));
let linkable = feather || size || (pct && prop.match_id == "scale");
let linked = linkable && app.ui.timeline.is_linked(uid, !size);
let linkable = feather || size || levels || (pct && prop.match_id == "scale");
let linked = linkable && app.ui.timeline.is_linked(uid, !(size || levels));
if linkable {
let lr = Rect::from_center_size(pos2(x + 7.0, at.y), vec2(14.0, 14.0));
let resp = ui.interact(lr, egui::Id::unique(("tl-link", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Constrain Proportions"));
let label = if levels { "Link Channels" } else { "Constrain Proportions" };
let resp = ui.interact(lr, egui::Id::unique(("tl-link", uid)), Sense::click()).on_hover_text(crate::i18n::tr(label));
icons::paint(p, lr, Icon::Link, if linked { t.accent } else { t.text_faint });
app.auto.add(&format!("timeline.prop.{uid}.link"), lr, "Constrain Proportions");
app.auto.add(&format!("timeline.prop.{uid}.link"), lr, label);
if resp.clicked() {
app.ui.timeline.toggle_link(uid, !size);
app.ui.timeline.toggle_link(uid, !(size || levels));
}
x += 18.0;
}
@@ -5696,7 +5718,8 @@ fn vector_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>
// Alt edits one value of a linked pair (Ctrl+Alt is a proportional scrub).
let shown = if linked && !ui.input(|i| i.modifiers.alt && !widgets::proportional_scrub(i.modifiers)) { n.min(c.len()) } else { 1 };
let from = if shown == 1 { d } else { 0 };
for (e, v) in widgets::constrained(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() {
let link = if levels { widgets::shifted } else { widgets::constrained };
for (e, v) in link(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() {
if let Some(slot) = nc.get_mut(from + e) {
*slot = v;
}
@@ -5704,8 +5727,15 @@ fn vector_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>
ve.set(actions, json!(nc));
// Typed into these components only: other selected layers keep the rest of
// their own value (#1434).
// Linked Audio Levels: the typed channel, the other following by that layer's own
// change, so each keeps its balance (#2485).
if let Some((_, a)) = actions.last_mut() {
a["dims"] = json!((from..from + shown).collect::<Vec<_>>());
if levels {
a["dims"] = json!([d]);
a["follow"] = json!((from..from + shown).filter(|&e| e != d).collect::<Vec<_>>());
} else {
a["dims"] = json!((from..from + shown).collect::<Vec<_>>());
}
}
}
x = r.max.x + if d + 1 < n { 2.0 } else { 0.0 };
@@ -5754,7 +5784,7 @@ fn bool_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>,
let t = app.tokens;
let x = at.x;
let r = Rect::from_min_size(pos2(x, at.y - 8.0), vec2(16.0, 16.0));
if widgets::checkbox(ui, r, *b, &t, egui::Id::unique(("cb", uid))).clicked() {
if widgets::checkbox(ui, r, *b, &t, egui::Id::unique(("cb", uid))).changed() {
ve.set(actions, json!(!b));
}
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
@@ -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, &copy));
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();
+7 -7
View File
@@ -257,10 +257,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
("scale", "Scale", settings.as_ref().is_some_and(|s| s.scale)),
] {
let cr = Rect::from_min_size(pos2(xx, y + 2.0), vec2(16.0, 16.0));
let resp = widgets::checkbox(ui, cr, on && transformish, &t, egui::Id::unique(("tracker-cb", key)));
let resp = ui.add_enabled_ui(transformish, |ui| widgets::checkbox(ui, cr, on && transformish, &t, egui::Id::unique(("tracker-cb", key)))).inner;
app.auto.add(&format!("tracker.{key}"), cr, text);
p.text(pos2(cr.max.x + 4.0, cr.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), if transformish { t.text } else { t.text_faint });
if resp.clicked() && transformish {
if resp.changed() && transformish {
run(app, &ctx, "track.setType", with(json!({ key: !on })));
}
xx += 84.0;
@@ -517,20 +517,20 @@ pub fn options_dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens)
ui.add_space(8.0);
ui.label(egui::RichText::new(crate::i18n::tr("Process Before Match")).font(Tokens::medium(12.5)));
ui.horizontal(|ui| {
let r = ui.checkbox(&mut d.blur_on, crate::i18n::tr("Blur"));
let r = crate::widgets::labelled_checkbox(ui, &mut d.blur_on, crate::i18n::tr("Blur"));
app.auto.add("dialog.trackOptions.blurOn", r.rect, "Blur");
let r =
ui.add_enabled(d.blur_on, crate::widgets::drag_value(egui::DragValue::new(&mut d.opts.blur).speed(0.1).range(0.5..=50.0).suffix(" pixels")));
app.auto.add("dialog.trackOptions.blur", r.rect, "Blur pixels");
let r = ui.checkbox(&mut d.opts.enhance, crate::i18n::tr("Enhance"));
let r = crate::widgets::labelled_checkbox(ui, &mut d.opts.enhance, crate::i18n::tr("Enhance"));
app.auto.add("dialog.trackOptions.enhance", r.rect, "Enhance");
});
ui.add_space(8.0);
let r = ui.checkbox(&mut d.opts.subpixel, crate::i18n::tr("Subpixel Positioning"));
let r = crate::widgets::labelled_checkbox(ui, &mut d.opts.subpixel, crate::i18n::tr("Subpixel Positioning"));
app.auto.add("dialog.trackOptions.subpixel", r.rect, "Subpixel Positioning");
let r = ui.checkbox(&mut d.opts.adapt_every_frame, crate::i18n::tr("Adapt Feature On Every Frame"));
let r = crate::widgets::labelled_checkbox(ui, &mut d.opts.adapt_every_frame, crate::i18n::tr("Adapt Feature On Every Frame"));
app.auto.add("dialog.trackOptions.adaptEveryFrame", r.rect, "Adapt Feature On Every Frame");
let r = ui.checkbox(&mut d.opts.track_shape, crate::i18n::tr("Follow Feature Rotation and Scale"));
let r = crate::widgets::labelled_checkbox(ui, &mut d.opts.track_shape, crate::i18n::tr("Follow Feature Rotation and Scale"));
app.auto.add("dialog.trackOptions.trackShape", r.rect, "Follow Feature Rotation and Scale");
ui.add_space(8.0);
ui.horizontal(|ui| {
+12 -8
View File
@@ -1362,14 +1362,18 @@ fn key_event(
exec(app, "layer.setText", json!({"layer": sel.layer.0, "range": [r.start, r.end], "size": ns}));
}
// The app-wide shortcut dispatcher is skipped while this editor holds the
// keyboard, so the Deselect All chord (Cmd/Ctrl+Shift+A or F2 by default)
// must be matched here and run through the command dispatcher like undo
// above. Matching the command's current binding, not a fixed key, keeps
// rebound shortcuts working and gives every route one final state; the
// forwarding test also refuses chords a focused editor must never surrender
// (see `forwardable_during_text_edit`).
_ if crate::menus::chord_matches(&app.session, "edit.deselectAll", key, m) && crate::menus::forwardable_during_text_edit(m, key) => {
let _ = crate::menus::invoke(app, ctx, "edit.deselectAll", json!({}));
// keyboard, so commands that end the edit by moving the selection — Deselect
// All (Cmd/Ctrl+Shift+A or F2 by default) and Duplicate (Cmd/Ctrl+D) — must be
// matched here and run through the command dispatcher like undo above.
// Matching each command's current binding, not a fixed key, keeps rebound
// shortcuts working and gives every route one final state; a chord bound to
// both commands resolves in list order. The forwarding test also refuses
// chords a focused editor must never surrender (see
// `forwardable_during_text_edit`).
_ if crate::menus::forwardable_during_text_edit(m, key)
&& let Some(cmd_id) = ["edit.deselectAll", "edit.duplicate"].into_iter().find(|id| crate::menus::chord_matches(&app.session, id, key, m)) =>
{
let _ = crate::menus::invoke(app, ctx, cmd_id, json!({}));
}
_ => {}
}
+18 -4
View File
@@ -308,8 +308,8 @@ impl Default for ViewerState {
impl TimelineState {
/// Whether property `uid`'s chain link (Constrain Proportions) is on. One that starts linked
/// (Scale, Mask Feather) records a click in `unlinked`; one that starts unlinked (shape Size)
/// records it in `linked`.
/// (Scale, Mask Feather) records a click in `unlinked`; one that starts unlinked (shape Size,
/// Audio Levels) records it in `linked`.
pub fn is_linked(&self, uid: u64, linked_by_default: bool) -> bool {
if linked_by_default { !self.unlinked.contains(&uid) } else { self.linked.contains(&uid) }
}
@@ -322,6 +322,16 @@ impl TimelineState {
}
}
/// Forget the chain links when the project was replaced (new or opened): they are keyed by
/// property uid, which the next project reuses, so a link turned on must not carry into it.
pub fn scope_links(&mut self, project_epoch: u64) {
if self.link_epoch != project_epoch {
self.link_epoch = project_epoch;
self.unlinked.clear();
self.linked.clear();
}
}
/// Show `kinds` on `layers` (twirled open; empty: twirled closed) and remember it as the last
/// reveal. Other layers keep theirs.
pub fn apply_reveal(&mut self, layers: &[u64], kinds: Vec<String>) {
@@ -429,10 +439,13 @@ pub struct TimelineState {
/// was turned off.
#[serde(default)]
pub unlinked: BTreeSet<u64>,
/// Shape Rectangle and Ellipse Path Size properties whose chain link (off by default) was
/// turned on (#2489).
/// Shape Rectangle and Ellipse Path Size (#2489) and Audio Levels (#2485) properties whose
/// chain link (off by default) was turned on.
#[serde(default)]
pub linked: BTreeSet<u64>,
/// The project (its epoch) `unlinked` and `linked` belong to.
#[serde(skip)]
pub link_epoch: u64,
/// Visible optional columns (column header right-click ▸ Columns): `av`, `keys`, `label`,
/// `num`, `comment`, `switches`, `parent`, `in`, `out`, `duration`, `stretch`. The name
/// column is always shown; Modes follows `show_modes` (F4).
@@ -486,6 +499,7 @@ impl Default for TimelineState {
expr_lines: BTreeMap::new(),
unlinked: BTreeSet::new(),
linked: BTreeSet::new(),
link_epoch: 0,
columns: default_tl_columns(),
source_name: false,
scroll_to: None,
+364 -2
View File
@@ -415,9 +415,173 @@ pub fn text_button(ui: &mut Ui, rect: Rect, text: &str, primary: bool, t: &Token
resp
}
/// Checkbox in the AE look (small square).
/// A primary-button gesture shared by custom and labelled checkboxes in this context.
#[derive(Clone, Default)]
struct CheckboxDrag {
press_frame: u64,
target: Option<bool>,
visited: std::collections::HashSet<egui::Id>,
/// Checkboxes the pointer crossed in the frame of the press, before the checkbox it pressed on
/// had been drawn (so the value to set was not known): they take it in the next frame.
pending: std::collections::HashSet<egui::Id>,
}
/// A primary-button event of the frame, in the order it happened.
#[derive(Clone, Copy, PartialEq)]
enum Step {
Press,
Move,
Release,
}
/// The frame's primary-button events in order, each with its position. egui hit-tests the whole
/// frame at the last pointer position, so a press followed by a move in one frame would otherwise
/// be taken for a press where the pointer ended.
fn pointer_steps(ui: &Ui) -> Vec<(Step, egui::Pos2)> {
ui.input(|i| {
i.events
.iter()
.filter_map(|e| match e {
egui::Event::PointerMoved(pos) => Some((Step::Move, *pos)),
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, .. } => {
Some((if *pressed { Step::Press } else { Step::Release }, *pos))
}
_ => None,
})
.collect()
})
}
/// Returns the requested value. Mixed checkboxes (`None`) and disabled controls are
/// skipped. Each checkbox is visited once, so crossing it again cannot toggle it back. The
/// checkbox a press lands on, and the ones the pointer passes over after it, are found from the
/// frame's ordered pointer events, not from where the pointer ended.
fn drag_toggle(ui: &Ui, response: &Response, value: Option<bool>) -> Option<bool> {
let (pressed, down, released) = ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_down(), i.pointer.primary_released()));
let frame = ui.ctx().cumulative_frame_nr();
// clicked() reads Context input; do not call it while Context data is locked.
let clicked = response.clicked();
// Whether the pointer at `pos` is over this checkbox itself (not a window above it).
let over = |pos: egui::Pos2| response.rect.contains(pos) && ui.clip_rect().contains(pos) && ui.ctx().layer_id_at(pos) == Some(response.layer_id);
// Resolved before the data lock below: layer_id_at reads the Context.
let steps: Vec<(Step, bool)> = pointer_steps(ui).into_iter().map(|(step, pos)| (step, over(pos))).collect();
ui.data_mut(|data| {
let drag = data.get_temp_mut_or_default::<CheckboxDrag>(egui::Id::unique("checkbox-drag"));
// A release in the press frame leaves crossings pending: keep them for the next frame.
let flushing = !drag.pending.is_empty() && drag.target.is_some() && frame <= drag.press_frame.saturating_add(1);
if (!down && !released && !flushing) || (pressed && drag.press_frame != frame) {
*drag = CheckboxDrag { press_frame: frame, ..Default::default() };
}
let on = value?;
if !response.enabled() {
return None;
}
// Crossed in the frame of the press before the value was known: take it now.
if drag.pending.remove(&response.id)
&& let Some(target) = drag.target
&& drag.visited.insert(response.id)
&& on != target
{
return Some(target);
}
// Whether the button is held, tracked in event order: a move after a release in the same frame is no drag.
// Before the first button event it is held exactly when that event is a release (or, with none, when it is down).
let mut held = match steps.iter().find(|(step, _)| *step != Step::Move) {
Some((step, _)) => *step == Step::Release,
None => down,
};
let mut press_seen = false;
for (step, over) in steps {
match step {
Step::Press => {
press_seen = true;
held = true;
if drag.target.is_none() && over {
drag.target = Some(!on);
drag.visited.insert(response.id);
return Some(!on);
}
}
Step::Release => held = false,
Step::Move if held => match drag.target {
Some(target) if over => {
if drag.visited.insert(response.id) && on != target {
return Some(target);
}
}
None if press_seen && over => {
drag.pending.insert(response.id);
}
_ => {}
},
_ => {}
}
}
// The pointer held still over this checkbox in a later frame of the gesture.
if down
&& !pressed
&& response.contains_pointer()
&& let Some(target) = drag.target
{
if drag.visited.insert(response.id) && on != target {
return Some(target);
}
return None;
}
// The source already changed on press. Suppress its ordinary release click.
if released && drag.target.is_some() {
return None;
}
clicked.then_some(!on)
})
}
/// A labelled checkbox with the same drag behavior as the custom panel checkbox.
/// Use through `ui.add`, `ui.put`, or `ui.add_enabled` like `egui::Checkbox`.
pub struct DragCheckbox<'a> {
value: &'a mut bool,
text: egui::WidgetText,
}
impl<'a> DragCheckbox<'a> {
pub fn new(value: &'a mut bool, text: impl Into<egui::WidgetText>) -> Self {
Self { value, text: text.into() }
}
}
impl egui::Widget for DragCheckbox<'_> {
fn ui(self, ui: &mut Ui) -> Response {
let before = *self.value;
let mut response = ui.add(egui::Checkbox::new(self.value, self.text));
// egui changes on release; our shared gesture changes on press and entry.
*self.value = before;
response.flags.remove(egui::response::Flags::CHANGED);
if response.clicked() {
ui.ctx().request_repaint();
}
if let Some(value) = drag_toggle(ui, &response, Some(before)) {
*self.value = value;
response.mark_changed();
ui.ctx().request_repaint();
}
response
}
}
/// A labelled checkbox that allocates its own rectangle.
pub fn labelled_checkbox(ui: &mut Ui, value: &mut bool, text: impl Into<egui::WidgetText>) -> Response {
ui.add(DragCheckbox::new(value, text))
}
/// Checkbox in the AE look (small square). `changed()` requests a toggle, including
/// keyboard activation and entry during a drag begun on another checkbox.
pub fn checkbox(ui: &mut Ui, rect: Rect, on: bool, t: &Tokens, id: egui::Id) -> Response {
let resp = ui.interact(rect, id, Sense::click());
let mut resp = ui.interact(rect, id, Sense::click());
let next = drag_toggle(ui, &resp, Some(on));
if next.is_some() {
resp.mark_changed();
}
let on = next.unwrap_or(on);
let b = Rect::from_center_size(rect.center(), vec2(12.0, 12.0));
ui.painter().rect_filled(b, 2.0, if on { t.accent } else { t.field_bg });
ui.painter().rect_stroke(b, 2.0, Stroke::new(1.0, if on { t.accent } else { t.field_border }), StrokeKind::Inside);
@@ -820,10 +984,208 @@ pub(crate) fn constrained(c: &[f64], d: usize, v: f64) -> Vec<f64> {
.collect()
}
/// Linked values after component `d` of `c` became `v`: the others move by the same amount, so
/// linked Audio Levels keep their stereo balance in dB (#2485).
pub(crate) fn shifted(c: &[f64], d: usize, v: f64) -> Vec<f64> {
let Some(&old) = c.get(d) else { return c.to_vec() };
c.iter().enumerate().map(|(e, &x)| if e == d { v } else { x + (v - old) }).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn checkbox_frame(ctx: &egui::Context, values: &mut [Option<bool>; 5], events: Vec<egui::Event>) {
let t = Tokens::for_kind(Default::default());
let input = egui::RawInput { events, ..Default::default() };
let mut output = ctx.run_ui(input, |ui| {
for (index, value) in values.iter_mut().enumerate() {
let rect = Rect::from_min_size(pos2(20.0, 20.0 + index as f32 * 30.0), vec2(120.0, 20.0));
ui.add_enabled_ui(index != 3, |ui| {
if index == 4 {
if let Some(on) = value {
ui.put(rect, DragCheckbox::new(on, crate::i18n::tr("Grid")));
}
} else if let Some(on) = value {
if checkbox(ui, rect, *on, &t, egui::Id::unique(index)).changed() {
*on = !*on;
}
} else {
let response = ui.interact(rect, egui::Id::unique(index), Sense::click());
assert_eq!(drag_toggle(ui, &response, None), None);
}
});
}
});
output.textures_delta.clear();
}
fn checkbox_pointer(y: f32, pressed: Option<bool>) -> Vec<egui::Event> {
let pos = pos2(28.0, y);
let mut events = vec![egui::Event::PointerMoved(pos)];
if let Some(pressed) = pressed {
events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
}
events
}
#[test]
fn checkbox_drag_sets_one_state_skips_mixed_and_disabled_and_ends_on_release() {
let ctx = egui::Context::default();
let mut values = [Some(false), Some(true), None, Some(false), Some(false)];
checkbox_frame(&ctx, &mut values, vec![]);
checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, Some(true)));
assert_eq!(values[0], Some(true));
// An already-matching neighbour must not invert, even after revisiting.
for y in [58.0, 88.0, 118.0, 148.0, 58.0, 28.0] {
checkbox_frame(&ctx, &mut values, checkbox_pointer(y, None));
}
assert_eq!(values, [Some(true), Some(true), None, Some(false), Some(true)]);
checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, Some(false)));
assert_eq!(values[0], Some(true), "release must not toggle the source again");
// Begin on the labelled wrapper and drag back into custom checkboxes.
checkbox_frame(&ctx, &mut values, checkbox_pointer(148.0, Some(true)));
assert_eq!(values[4], Some(false));
for y in [58.0, 28.0] {
checkbox_frame(&ctx, &mut values, checkbox_pointer(y, None));
}
checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, Some(false)));
assert_eq!(values, [Some(false), Some(false), None, Some(false), Some(false)]);
checkbox_frame(&ctx, &mut values, checkbox_pointer(58.0, None));
assert_eq!(values[1], Some(false), "hover after release must not paint");
}
fn button(y: f32, pressed: bool) -> Vec<egui::Event> {
let pos = pos2(28.0, y);
vec![egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }]
}
fn moved(y: f32) -> egui::Event {
egui::Event::PointerMoved(pos2(28.0, y))
}
/// A press and the moves after it can arrive in one frame (a quick flick): the press belongs to
/// the checkbox it landed on, not to the one the pointer ended over, and the ones passed over
/// follow the pressed one's new state, whether they are drawn before it or after it.
#[test]
fn a_press_and_a_move_in_one_frame_start_on_the_checkbox_pressed() {
// Rows are 30 apart, from y = 28; index 3 is disabled.
let (a, b) = (28.0, 58.0);
// Pressed on the first, moved to the second (drawn after it).
let ctx = egui::Context::default();
let mut values = [Some(false); 5];
values[2] = None;
checkbox_frame(&ctx, &mut values, vec![]);
let mut events = vec![moved(a)];
events.extend(button(a, true));
events.push(moved(b));
checkbox_frame(&ctx, &mut values, events);
assert_eq!((values[0], values[1]), (Some(true), Some(true)), "the pressed one is the source, the one passed over follows");
let mut events = vec![moved(b)];
events.extend(button(b, false));
checkbox_frame(&ctx, &mut values, events);
assert_eq!((values[0], values[1]), (Some(true), Some(true)), "release changes nothing");
// Pressed on the second, moved to the first (drawn before it): the first takes the state in
// the next frame, while the button is still down.
let ctx = egui::Context::default();
let mut values = [Some(false); 5];
values[2] = None;
checkbox_frame(&ctx, &mut values, vec![]);
let mut events = vec![moved(b)];
events.extend(button(b, true));
events.push(moved(a));
checkbox_frame(&ctx, &mut values, events);
assert_eq!(values[1], Some(true), "the pressed one is the source");
checkbox_frame(&ctx, &mut values, vec![]);
assert_eq!(values[0], Some(true), "the one passed over takes the state");
let mut events = vec![moved(a)];
events.extend(button(a, false));
checkbox_frame(&ctx, &mut values, events);
assert_eq!((values[0], values[1]), (Some(true), Some(true)));
// Pressed outside every checkbox, then moved onto one in the same frame: nothing starts.
let ctx = egui::Context::default();
let mut values = [Some(false); 5];
values[2] = None;
checkbox_frame(&ctx, &mut values, vec![]);
let mut events = vec![moved(200.0)];
events.extend(button(200.0, true));
events.push(moved(a));
checkbox_frame(&ctx, &mut values, events);
checkbox_frame(&ctx, &mut values, vec![]);
assert_eq!(values, [Some(false), Some(false), None, Some(false), Some(false)], "a press outside starts no gesture");
// Pressed on the second, moved to the first (drawn before it) and released, all in one
// frame: the first still takes the state in the next frame.
let ctx = egui::Context::default();
let mut values = [Some(false); 5];
values[2] = None;
checkbox_frame(&ctx, &mut values, vec![]);
let mut events = vec![moved(b)];
events.extend(button(b, true));
events.push(moved(a));
events.extend(button(a, false));
checkbox_frame(&ctx, &mut values, events);
checkbox_frame(&ctx, &mut values, vec![]);
assert_eq!((values[0], values[1]), (Some(true), Some(true)), "a crossing before the release still takes the state");
}
/// A move that arrives after the release, in the frame of the click, is the pointer travelling without the button:
/// it must not carry the gesture onto the checkbox it ends over.
#[test]
fn a_move_after_the_release_in_the_same_frame_paints_nothing() {
let (a, b) = (28.0, 58.0);
let ctx = egui::Context::default();
let mut values = [Some(false); 5];
values[2] = None;
checkbox_frame(&ctx, &mut values, vec![]);
let mut events = vec![moved(a)];
events.extend(button(a, true));
events.extend(button(a, false));
events.push(moved(b));
checkbox_frame(&ctx, &mut values, events);
checkbox_frame(&ctx, &mut values, vec![]);
assert_eq!((values[0], values[1]), (Some(true), Some(false)), "the clicked one changes, the one moved onto afterwards does not");
}
#[test]
fn checkbox_click_and_keyboard_toggle_once_and_drag_must_start_on_a_checkbox() {
let ctx = egui::Context::default();
let mut values = [Some(false), Some(false), None, Some(false), Some(false)];
checkbox_frame(&ctx, &mut values, vec![]);
// Pressing outside the checkboxes must not start a gesture on entry.
checkbox_frame(&ctx, &mut values, checkbox_pointer(200.0, Some(true)));
checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, None));
checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, Some(false)));
assert_eq!(values[0], Some(false));
for source_y in [88.0, 118.0] {
checkbox_frame(&ctx, &mut values, checkbox_pointer(source_y, Some(true)));
checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, None));
checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, Some(false)));
assert_eq!(values[0], Some(false), "mixed and disabled sources cannot begin a drag");
}
for y in [28.0, 148.0] {
checkbox_frame(&ctx, &mut values, checkbox_pointer(y, Some(true)));
checkbox_frame(&ctx, &mut values, checkbox_pointer(y, Some(false)));
}
assert_eq!(values[0], Some(true));
assert_eq!(values[4], Some(true));
ctx.memory_mut(|m| m.request_focus(egui::Id::unique(0usize)));
checkbox_frame(
&ctx,
&mut values,
vec![egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }],
);
assert_eq!(values[0], Some(false), "keyboard activation still works");
// Fast clicks may deliver press and release together in one input frame.
// This also exercises clicked() without an active drag (outside the data lock).
for (index, y) in [(0, 28.0), (4, 148.0)] {
let before = values[index];
let mut events = checkbox_pointer(y, Some(true));
events.extend(checkbox_pointer(y, Some(false)));
checkbox_frame(&ctx, &mut values, events);
assert_eq!(values[index], before.map(|on| !on));
}
}
/// #2097: the drag speed comes from the slider range, not a much wider hard range.
#[test]
fn slider_scrub_speed_uses_the_slider_range() {
+3
View File
@@ -15,6 +15,7 @@ mod ui_blank_context_menus;
mod ui_camera_tracker;
mod ui_character_eyedropper;
mod ui_character_stroke_order;
mod ui_checkbox_drag;
mod ui_color_hex;
mod ui_comp_presets;
mod ui_control_modifiers;
@@ -76,7 +77,9 @@ mod ui_text_edit;
mod ui_text_range_handles;
mod ui_theme_favorites;
mod ui_time_zoom_controls;
mod ui_timeline_audio_levels;
mod ui_timeline_effect_label;
mod ui_timeline_group_select;
mod ui_timeline_pan;
mod ui_timeline_shapes;
mod ui_timeline_switches;
@@ -0,0 +1,54 @@
//! #1190: the shared drag gesture also reaches real dialog controls.
use effectcraft_engine::Session;
use effectcraft_ui_egui::{EffectcraftApp, menus};
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::{Harness, kittest::Queryable as _};
use serde_json::json;
fn pointer(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, pressed: Option<bool>) {
h.event(Event::PointerMoved(at));
h.step();
if let Some(pressed) = pressed {
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(2);
}
#[test]
fn view_options_drag_checks_and_unchecks_neighbours() {
let mut session = Session::default();
session.prefs.disk.disk_cache_enabled = false;
session.execute("comp.new", json!({"width":32,"height":32,"duration":1})).unwrap();
let mut h = Harness::builder().with_size(vec2(1200.0, 900.0)).build_eframe(|_| EffectcraftApp::new(session));
h.state_mut().ui.viewer.rulers = false;
h.state_mut().ui.viewer.guides = true;
h.state_mut().ui.viewer.grid = false;
h.run_steps(3);
let ctx = h.ctx.clone();
menus::invoke(h.state_mut(), &ctx, "view.options", json!({})).unwrap();
h.run_steps(3);
let rulers = h.get_by_label("Rulers").rect().center();
let guides = h.get_by_label("Guides").rect().center();
let grid = h.get_by_label("Grid").rect().center();
pointer(&mut h, rulers, Some(true));
assert!(h.state().ui.viewer.rulers);
pointer(&mut h, guides, None);
assert!(h.state().ui.viewer.guides, "an already-checked neighbour stays checked");
pointer(&mut h, grid, None);
assert!(h.state().ui.viewer.grid);
pointer(&mut h, rulers, None);
pointer(&mut h, rulers, Some(false));
assert!(h.state().ui.viewer.rulers, "revisiting and releasing does not undo the change");
if let Ok(path) = std::env::var("CHECKBOX_REVIEW_PNG") {
h.render().unwrap().save(path).unwrap();
}
pointer(&mut h, grid, Some(true));
pointer(&mut h, guides, None);
pointer(&mut h, rulers, None);
pointer(&mut h, rulers, Some(false));
let viewer = &h.state().ui.viewer;
assert!(!viewer.rulers && !viewer.guides && !viewer.grid);
pointer(&mut h, guides, None);
assert!(!h.state().ui.viewer.guides, "hover after release does not change a checkbox");
}
+72
View File
@@ -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);
}
+122 -1
View File
@@ -8,6 +8,7 @@ use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::dock::PanelKind;
use egui::{Event, Key, Modifiers, PointerButton, Pos2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
fn app() -> EffectcraftApp {
@@ -580,7 +581,7 @@ fn type_tool_selected_text_preference_skips_ineligible_layers_and_falls_back_to_
}
/// Opens a fresh Type-tool edit holding `text` and returns the harness with the new layer id,
/// so the Deselect All tests start from the same state.
/// so the Deselect All and Duplicate tests start from the same state.
fn type_tool_edit(a: EffectcraftApp, text: &str) -> (Harness<'static, EffectcraftApp>, u64) {
let mut h = harness(a);
h.run_steps(3);
@@ -728,6 +729,110 @@ fn deselect_all_menu_item_follows_the_edit_while_the_project_panel_is_focused()
assert!(!enabled(&h), "Deselect All follows the empty Project selection once the edit is over");
}
/// Layer ids of the active comp, bottom to top.
fn layer_ids(h: &Harness<'_, EffectcraftApp>) -> Vec<u64> {
h.state().session.active_comp().map_or(vec![], |c| c.layers.iter().map(|l| l.id.0).collect())
}
#[test]
fn duplicate_chord_ends_a_type_tool_edit_and_selects_the_copy() {
let (mut h, lid) = type_tool_edit(app(), "Hello");
key(&mut h, Key::D, Modifiers::COMMAND);
let s = &h.state().session;
assert!(s.state.text_edit.is_none(), "Duplicate ends the open edit, as Deselect All does");
let copy = *s.state.selected_layers.first().expect("the copy is selected");
assert_ne!(copy.0, lid, "the copy (not the edited original) holds the selection");
assert_eq!(layer_doc(s, LayerId(lid)).unwrap().text, "Hello", "the original keeps its text");
assert_eq!(layer_doc(s, copy).unwrap().text, "Hello", "the copy carries it too");
}
#[test]
fn duplicate_chord_ends_an_edit_started_while_the_project_panel_is_focused() {
let (mut h, lid) = project_focused_text_edit("Hi");
key(&mut h, Key::D, Modifiers::COMMAND);
let s = &h.state().session;
assert!(s.state.text_edit.is_none(), "Cmd+D ends the edit even with Project focused");
let copy = s.state.selected_layers.first().expect("the copy is selected");
assert_eq!(layer_doc(s, LayerId(lid)).unwrap().text, "Hi", "the typed text is kept");
assert_eq!(layer_doc(s, *copy).unwrap().text, "Hi", "on the copy as well");
}
#[test]
fn rebound_duplicate_chord_forwards_from_inside_the_edit() {
let mut a = app();
a.session.execute("shortcuts.set", json!({"command": "edit.duplicate", "keys": ["Cmd+Alt+D"]})).unwrap();
let (mut h, lid) = type_tool_edit(a, "Hello");
// The default key no longer belongs to Duplicate: while the editor holds the keyboard a
// chord that matches no command does nothing, and the current binding is what forwards.
let n = layer_ids(&h).len();
key(&mut h, Key::D, Modifiers::COMMAND);
assert!(h.state().session.state.text_edit.is_some(), "Cmd+D was rebound away: the edit stays open");
assert_eq!(layer_ids(&h).len(), n, "and nothing was duplicated");
key(&mut h, Key::D, Modifiers { alt: true, ..Modifiers::COMMAND });
let s = &h.state().session;
assert!(s.state.text_edit.is_none(), "the rebound chord ends the edit");
assert_eq!(layer_doc(s, LayerId(lid)).unwrap().text, "Hello", "the text is kept");
assert_eq!(layer_ids(&h).len(), n + 1, "and the copy exists");
}
#[test]
fn duplicate_rebound_onto_a_bare_letter_stays_typing() {
let mut a = app();
a.session.execute("shortcuts.set", json!({"command": "edit.duplicate", "keys": ["D"]})).unwrap();
let (mut h, lid) = type_tool_edit(a, "Hello");
let n = layer_ids(&h).len();
// A binding without a command modifier is text as far as the editor is concerned: the editor
// must not surrender a key that types (same invariant as the app-wide dispatcher).
key(&mut h, Key::D, Modifiers::NONE);
assert!(h.state().session.state.text_edit.is_some(), "a bare-letter Duplicate binding does not end the edit");
assert_eq!(layer_ids(&h).len(), n, "and does not duplicate");
type_text(&mut h, "D");
let (lid2, text, _, _) = edited(&h);
assert_eq!(lid2, lid, "the same edit on the same layer continues");
assert_eq!(text, "HelloD", "and the letter typed into the text");
}
#[test]
fn duplicate_chord_copies_the_edited_layer_not_an_effect_still_selected() {
let mut a = app();
let lid = a.session.execute("layer.newText", json!({"text": "alpha beta", "size": 40, "box": [40, 60, 250, 100]})).unwrap()["layer"].as_u64().unwrap();
// Applying the blur selects it, as clicking it in Effect Controls does.
a.session.execute("effect.apply", json!({"layers": [lid], "effect": "Gaussian Blur"})).unwrap();
a.ui.tool = effectcraft_ui_egui::state::Tool::Type;
let mut h = harness(a);
h.run_steps(3);
let comp = rect(&h, "viewer.comp");
click(&mut h, comp.min + egui::vec2(80.0, 85.0) * (comp.width() / 640.0), Modifiers::NONE);
assert_eq!(edited(&h).0, lid, "the Type tool's click opens the text layer for editing");
assert!(!h.state().session.state.selected_props.is_empty(), "and the blur stays selected");
let n = layer_ids(&h).len();
key(&mut h, Key::D, Modifiers::COMMAND);
let s = &h.state().session;
assert!(s.state.text_edit.is_none(), "Duplicate ends the edit");
assert_eq!(layer_ids(&h).len(), n + 1, "and copies the edited layer");
let blurs = s.active_comp().unwrap().layer(LayerId(lid)).unwrap().effects().map_or(0, |fx| fx.groups().count());
assert_eq!(blurs, 1, "not the blur");
}
#[test]
fn a_chord_bound_to_both_forwarded_commands_resolves_in_list_order() {
let mut a = app();
// Shortcuts may hold one chord for several commands (the conflict is a toast, not a
// refusal), so the editor's forwarding resolves a shared chord in list order — Deselect
// All before Duplicate. This pins that documented contract: the shared chord must end the
// edit like Deselect All, without running Duplicate behind it.
a.session.execute("shortcuts.set", json!({"command": "edit.deselectAll", "keys": ["Cmd+Alt+G"]})).unwrap();
a.session.execute("shortcuts.set", json!({"command": "edit.duplicate", "keys": ["Cmd+Alt+G"]})).unwrap();
let (mut h, lid) = type_tool_edit(a, "Hello");
let n = layer_ids(&h).len();
key(&mut h, Key::G, Modifiers { alt: true, ..Modifiers::COMMAND });
let s = &h.state().session;
assert!(s.state.text_edit.is_none(), "the shared chord still ends the edit");
assert!(s.state.selected_layers.is_empty(), "Deselect All wins the shared chord: nothing is selected");
assert_eq!(layer_ids(&h).len(), n, "so the chord did not duplicate");
assert_eq!(layer_doc(s, LayerId(lid)).unwrap().text, "Hello", "and the text is kept");
}
/// The on-canvas editor's outgoing IME producer: the descriptor carries the edited layer's
/// committed text, the real owner, a stable generation and an explicit change cause. A normal
/// typed character is application work; an accepted input-method commit is not.
@@ -1750,3 +1855,19 @@ fn character_panel_line_join_buttons_set_the_join() {
click(&mut h, bevel, Modifiers::NONE);
assert_eq!(join(&h), effectcraft_engine::keyframe::LineJoin::Bevel);
}
/// #2075: the Character panel's stroke width has a presets menu (0.25 … 100 px) beside its value.
#[test]
fn character_panel_stroke_width_presets_set_the_width() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap();
let lid = s.execute("layer.newText", json!({"text": "Stroke", "size": 80, "applyStroke": true, "strokeWidth": 8})).unwrap()["layer"].as_u64().unwrap();
let mut h = harness(EffectcraftApp::new(s));
h.state_mut().show_panel(PanelKind::Character);
h.run_steps(3);
let arrow = rect(&h, "character.strokeWidth.presets").center();
click(&mut h, arrow, Modifiers::NONE);
let entry = h.get_by_label("0.5 px").rect().center();
click(&mut h, entry, Modifiers::NONE);
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().stroke_width, 0.5);
}
@@ -0,0 +1,166 @@
//! Audio Levels' channel link in the Timeline (#2485): off by default; linked, an edit to one
//! channel moves the other by the same dB, so the stereo balance stays, typed or scrubbed, on a
//! static or animated property, and one Undo takes it back.
use effectcraft_engine::Session;
use effectcraft_engine::project::LayerId;
use effectcraft_ui_egui::EffectcraftApp;
use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2, vec2};
use egui_kittest::Harness;
use serde_json::json;
fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)
}
/// Press on `from`, move to `to` in `steps`, release there.
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, steps: u32) {
h.input_mut().events.push(Event::PointerMoved(from));
h.step();
h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
for k in 1..=steps {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / steps as f32)));
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
let p = center(h, id);
drag(h, p, p, 1);
}
fn type_value(h: &mut Harness<'_, EffectcraftApp>, id: &str, v: &str) {
click(h, id);
h.input_mut().events.push(Event::Text(v.into()));
h.step();
h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
/// A precomp layer (which has Audio Levels) in comp Main at `levels`; its layer id and the
/// property's uid.
fn add_sound_layer(s: &mut Session, levels: [f64; 2]) -> (u64, u64) {
let l = s.execute("layer.addItem", json!({"item": "Sound"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": l, "path": "audio/levels", "value": levels})).unwrap();
(l, s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("audio/levels").unwrap().uid)
}
/// Comps Sound and Main.
fn comps(s: &mut Session) {
s.execute("comp.new", json!({"name": "Sound", "width": 64, "height": 36, "duration": 4})).unwrap();
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap();
}
/// Twirl layer `l` open to its property `uid`.
fn reveal(h: &mut Harness<'_, EffectcraftApp>, l: u64, uid: u64) {
h.state_mut().ui.timeline.open_layers.insert(l);
h.state_mut().ui.timeline.layer_reveal.insert(l, vec!["props".into()]);
h.state_mut().ui.timeline.reveal_props.insert(uid);
h.run_steps(3);
}
fn levels_of(h: &Harness<'_, EffectcraftApp>, l: u64) -> Vec<f64> {
h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("audio/levels").unwrap().value.components()
}
#[test]
fn linked_audio_levels_move_both_channels_by_the_same_db() {
let mut s = Session::default();
comps(&mut s);
let (l, uid) = add_sound_layer(&mut s, [-6.0, -12.0]);
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
reveal(&mut h, l, uid);
let levels =
|h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("audio/levels").unwrap().clone();
let set = |h: &mut Harness<'_, EffectcraftApp>, v: [f64; 2]| {
h.state_mut().session.execute("prop.set", json!({"layer": l, "path": "audio/levels", "value": v})).unwrap();
h.run_steps(2);
};
let undo = |h: &mut Harness<'_, EffectcraftApp>| {
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
};
let (left, right, link) = (format!("timeline.prop.{uid}.value.0"), format!("timeline.prop.{uid}.value.1"), format!("timeline.prop.{uid}.link"));
assert_eq!(h.state().auto.find(&link).unwrap().label, "Link Channels");
type_value(&mut h, &left, "-3");
assert_eq!(levels(&h).value.components(), vec![-3.0, -12.0], "unlinked by default, one channel moves alone");
set(&mut h, [-6.0, -12.0]);
click(&mut h, &link);
type_value(&mut h, &left, "-3");
assert_eq!(levels(&h).value.components(), vec![-3.0, -9.0], "linked, both rise 3 dB and keep their 6 dB balance");
undo(&mut h);
assert_eq!(levels(&h).value.components(), vec![-6.0, -12.0], "one Undo takes back both channels");
type_value(&mut h, &right, "0");
assert_eq!(levels(&h).value.components(), vec![6.0, 0.0], "the right channel leads just the same");
set(&mut h, [0.0, 0.0]);
type_value(&mut h, &left, "-6");
assert_eq!(levels(&h).value.components(), vec![-6.0, -6.0], "equal channels at 0 dB stay equal");
set(&mut h, [-6.0, -12.0]);
let from = center(&h, &left);
drag(&mut h, from, from + vec2(30.0, 0.0), 6);
let c = levels(&h).value.components();
assert!(c[0] > -6.0, "the scrub raised the left channel: {c:?}");
assert!((c[0] - c[1] - 6.0).abs() < 1e-9, "a linked scrub keeps the balance: {c:?}");
undo(&mut h);
assert_eq!(levels(&h).value.components(), vec![-6.0, -12.0], "one Undo takes back the whole scrub");
// Animated: the edit at the current time changes that key's two channels together.
h.state_mut().session.execute("prop.addKey", json!({"layer": l, "path": "audio/levels", "time": 0.0, "value": [-6.0, -12.0]})).unwrap();
h.state_mut().session.execute("prop.addKey", json!({"layer": l, "path": "audio/levels", "time": 1.0, "value": [0.0, 0.0]})).unwrap();
h.run_steps(2);
type_value(&mut h, &left, "-10");
let keys: Vec<Vec<f64>> = levels(&h).keys.iter().map(|k| k.value.components()).collect();
assert_eq!(keys, vec![vec![-10.0, -16.0], vec![0.0, 0.0]], "the key at the current time moves both channels");
click(&mut h, &link);
type_value(&mut h, &left, "-4");
let keys: Vec<Vec<f64>> = levels(&h).keys.iter().map(|k| k.value.components()).collect();
assert_eq!(keys[0], vec![-4.0, -16.0], "unlinked again, the right channel stays");
}
/// With several layers selected, a linked typed Left sets each layer's Left and moves its Right
/// by that layer's own change: every layer keeps its own balance.
#[test]
fn linked_typing_keeps_each_selected_layers_own_balance() {
let mut s = Session::default();
comps(&mut s);
let (a, uid) = add_sound_layer(&mut s, [-6.0, -12.0]);
let (b, other) = add_sound_layer(&mut s, [-20.0, -30.0]);
s.execute("layer.select", json!({"layers": [a, b]})).unwrap();
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
reveal(&mut h, a, uid);
reveal(&mut h, b, other);
click(&mut h, &format!("timeline.prop.{uid}.link"));
type_value(&mut h, &format!("timeline.prop.{uid}.value.0"), "-3");
assert_eq!(levels_of(&h, a), vec![-3.0, -9.0]);
assert_eq!(levels_of(&h, b), vec![-3.0, -13.0], "B rises 17 dB on both channels, keeping its 10 dB balance");
}
/// A link turned on does not carry into a new project whose Audio Levels reuse the property uid.
#[test]
fn a_new_project_starts_with_its_audio_levels_unlinked() {
let mut s = Session::default();
comps(&mut s);
let (l, uid) = add_sound_layer(&mut s, [0.0, 0.0]);
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
reveal(&mut h, l, uid);
click(&mut h, &format!("timeline.prop.{uid}.link"));
assert!(h.state().ui.timeline.is_linked(uid, false));
h.state_mut().session.execute("file.newProject", json!({})).unwrap();
comps(&mut h.state_mut().session);
let (l, again) = add_sound_layer(&mut h.state_mut().session, [0.0, 0.0]);
assert_eq!(again, uid, "the new project reuses the property uid");
reveal(&mut h, l, again);
assert!(!h.state().ui.timeline.is_linked(again, false), "the new project's levels start unlinked");
type_value(&mut h, &format!("timeline.prop.{again}.value.0"), "-3");
assert_eq!(levels_of(&h, l), vec![-3.0, 0.0]);
}
@@ -0,0 +1,83 @@
//! Shift-clicking a group row in the Timeline adds it to the selection, as on a property row, so
//! several Puppet pins can be selected and deleted together (#1751).
use effectcraft_engine::Session;
use effectcraft_engine::project::{LayerId, PropGroup};
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::dock::PanelKind;
use egui::{Modifiers, Pos2, vec2};
use egui_kittest::Harness;
use serde_json::json;
fn harness() -> (Harness<'static, EffectcraftApp>, u64, [u64; 3]) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 200, "height": 100, "frameRate": 30, "duration": 2})).unwrap();
let layer = s.execute("layer.newSolid", json!({"width": 100, "height": 60, "color": [0, 0, 1]})).unwrap()["layer"].as_u64().unwrap();
let pin = |s: &mut Session, x: i64| s.execute("puppet.addPin", json!({"layer": layer, "position": [x, 30]})).unwrap()["pin"].as_u64().unwrap();
let pins = [pin(&mut s, 10), pin(&mut s, 50), pin(&mut s, 90)];
let mut app = EffectcraftApp::new(s);
app.show_panel(PanelKind::Timeline);
app.ui.timeline.open_layers.insert(layer);
// Twirl open every group down to the pins.
fn all(g: &PropGroup, out: &mut Vec<u64>) {
for c in g.groups() {
out.push(c.uid);
all(c, out);
}
}
let mut open = vec![];
if let Some(l) = app.session.active_comp().and_then(|c| c.layer(LayerId(layer))) {
all(&l.props, &mut open);
}
app.ui.timeline.open_groups.extend(open);
let mut h = Harness::builder().with_size(vec2(1600.0, 1100.0)).build_eframe(|_| app);
h.run_steps(4);
(h, layer, pins)
}
fn row(h: &Harness<'_, EffectcraftApp>, uid: u64) -> Pos2 {
let id = format!("timeline.group.{uid}.name");
let e = h.state().auto.find(&id).unwrap_or_else(|| panic!("no {id}"));
egui::pos2(e.rect[0] + e.rect[2].min(40.0) / 2.0, e.rect[1] + e.rect[3] / 2.0)
}
fn click(h: &mut Harness<'_, EffectcraftApp>, uid: u64, modifiers: Modifiers) {
let at = row(h, uid);
h.input_mut().events.push(egui::Event::ModifiersChanged(modifiers));
h.input_mut().events.push(egui::Event::PointerMoved(at));
h.input_mut().events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers });
h.input_mut().events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers });
h.run_steps(3);
h.input_mut().events.push(egui::Event::ModifiersChanged(Modifiers::NONE));
h.step();
}
fn selected(h: &Harness<'_, EffectcraftApp>, pins: [u64; 3]) -> Vec<u64> {
h.state().session.state.selected_props.iter().map(|(_, u)| *u).filter(|u| pins.contains(u)).collect()
}
fn pin_count(h: &Harness<'_, EffectcraftApp>, layer: u64) -> usize {
let l = h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap();
l.effects().unwrap().groups().map(effectcraft_engine::effects::puppet::pin_count).sum()
}
#[test]
fn shift_click_adds_puppet_pins_and_delete_removes_them_together() {
let (mut h, layer, pins) = harness();
let [a, b, c] = pins;
click(&mut h, a, Modifiers::NONE);
assert_eq!(selected(&h, pins), [a]);
click(&mut h, b, Modifiers::SHIFT);
assert_eq!(selected(&h, pins), [a, b], "Shift-click keeps the first pin");
click(&mut h, c, Modifiers::NONE);
assert_eq!(selected(&h, pins), [c], "a plain click selects only the clicked pin");
click(&mut h, a, Modifiers::NONE);
click(&mut h, b, Modifiers::SHIFT);
h.state_mut().session.execute("edit.clear", json!({})).unwrap();
h.run_steps(3);
assert_eq!(pin_count(&h, layer), 1, "Delete removes both selected pins");
assert!(h.state().session.active_comp().unwrap().layer(LayerId(layer)).is_some(), "the layer stays");
h.state_mut().session.undo();
assert_eq!(pin_count(&h, layer), 3, "one Undo restores both pins");
}
@@ -492,3 +492,35 @@ fn shift_click_selects_several_effects_for_a_preset() {
shift_click(&mut h, uids[1]);
assert_eq!(selected(&h), [uids[0]], "a second Shift-click takes it out");
}
/// #2513: the Text group's Animate menu adds Animator 1 with the chosen property (and a Range
/// Selector), and the animator's Add menu adds another property to it.
#[test]
fn text_animate_menu_adds_an_animator_with_the_property() {
let mut s = session();
let l = s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap();
let text = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("text").unwrap().uid;
let mut h = harness(s);
h.state_mut().ui.timeline.open_groups.insert(text);
open_layer(&mut h, l);
click(&mut h, &format!("timeline.group.{text}.animate"));
let entry = h.get_by_label("Position").rect();
click_at(&mut h, entry.center());
let animators = |h: &Harness<'_, EffectcraftApp>| {
h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("text").unwrap().sub("animators").cloned()
};
let anims = animators(&h).expect("an Animators group");
let a = anims.groups().next().expect("Animator 1");
let has = |a: &effectcraft_engine::project::PropGroup, m: &str| a.sub("properties").is_some_and(|p| p.children.iter().any(|c| c.match_id() == m));
assert!(has(a, "position"), "Position was added: {:?}", a.sub("properties"));
assert!(a.sub("selectors").is_some_and(|s| !s.children.is_empty()), "with a Range Selector");
// The animator's Add menu adds a property to it.
h.state_mut().ui.timeline.open_groups.insert(anims.uid);
h.state_mut().ui.timeline.open_groups.insert(a.uid);
h.run_steps(2);
click(&mut h, &format!("timeline.group.{}.add", a.uid));
let entry = h.get_by_label("Property: Opacity").rect();
click_at(&mut h, entry.center());
let a = animators(&h).unwrap().groups().next().unwrap().clone();
assert!(has(&a, "opacity"), "Opacity was added: {:?}", a.sub("properties"));
}
+3 -1
View File
@@ -11,7 +11,9 @@ written only under a write root; with no write root nothing is written. Every en
(`execute_command`, `command_run`, each `batch` step) is checked before it runs: its file
parameters must be absolute paths inside the roots (symbolic links resolved, no `..`), and so must
any other absolute path in its parameters. Save, Increment and Save and rendering the Render
Queue are checked against the files they write (the open project's, the queued outputs). Scripts
Queue are checked against the files they write (the open project's, the queued outputs);
`renderQueue.add`, `render.preRender` and `file.createProxy` without an output file need a saved
project, since an unsaved one's default output would go to the home folder (#2479). Scripts
(`run_script`, `file.runScript`…), Execute File, Reveal in Finder, `$N` references to file paths
in a batch, and the bridge's UI-driving tools (`ui_click`, `ui_key`, `ui_type`, `ui_drag`,
`ui_set`, `script_ui`, `control`) are refused while confined. Without the flags nothing changes.