mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 07:38:24 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
commit
0f39b3740d
71 files changed
+2617
-277
No files matched your search
@@ -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, ¶ms)? {
|
||||
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, ¶ms)? {
|
||||
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)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -56,6 +56,10 @@ fn compatible(a: &Property, b: &effectcraft_keyframe::Value) -> bool {
|
||||
a.value.kind_name() == b.kind_name() && a.value.components().len() == b.components().len()
|
||||
}
|
||||
|
||||
pub(crate) fn locked_selection(comp: &effectcraft_project::Comp, selected: &[KeyRef]) -> Vec<KeyRef> {
|
||||
selected.iter().filter(|k| comp.layer(k.layer).is_some_and(|l| l.switches.locked)).copied().collect()
|
||||
}
|
||||
|
||||
/// Paste copied keyframes at the current time into the target layers' properties.
|
||||
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let clip = s.state.key_clipboard.clone();
|
||||
@@ -90,6 +94,9 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut plan: Vec<(LayerId, Uid, usize)> = vec![];
|
||||
for (ti, lid) in targets.iter().enumerate() {
|
||||
let Some(l) = comp.layer(*lid) else { continue };
|
||||
if l.switches.locked {
|
||||
continue;
|
||||
}
|
||||
for (ci, c) in clip.iter().enumerate() {
|
||||
if paired && sources.get(ti) != Some(&c.layer) {
|
||||
continue;
|
||||
@@ -111,11 +118,12 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
if plan.is_empty() {
|
||||
return Err(bad("keys.paste", "no compatible property to paste into (select a property of the same type)"));
|
||||
return Err(bad("keys.paste", "no compatible unlocked property to paste into (unlock the target or select a property of the same type)"));
|
||||
}
|
||||
s.edit("Paste Keyframes", merge_p(p), |proj, st| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let mut sel = vec![];
|
||||
let mut sel = locked_selection(comp, &st.selected_keys);
|
||||
let retained = sel.len();
|
||||
for (lid, uid, ci) in &plan {
|
||||
let Some(l) = comp.layer_mut(*lid) else { continue };
|
||||
let times: Vec<Tick> = clip[*ci].keys.iter().map(|k| l.layer_time(t + k.time)).collect();
|
||||
@@ -137,8 +145,9 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
retime_roving(&mut pr.keys, pr.spatial);
|
||||
}
|
||||
let pasted = sel.len().saturating_sub(retained);
|
||||
st.selected_keys = sel;
|
||||
Ok(json!(st.selected_keys.len()))
|
||||
Ok(json!(pasted))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -176,6 +185,9 @@ pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, L
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let t = time_p(s, p, Some(comp));
|
||||
let target = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
if target.switches.locked {
|
||||
return Err(bad("keys.paste", "the target layer is locked"));
|
||||
}
|
||||
let mut order: Vec<usize> = (0..path.vertices.len()).collect();
|
||||
if path.closed && !order.is_empty() {
|
||||
order.push(0);
|
||||
@@ -203,6 +215,9 @@ pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, L
|
||||
})
|
||||
.collect();
|
||||
s.edit("Paste Keyframes", merge_p(p), |proj, st| {
|
||||
let comp = proj.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let mut selected = locked_selection(comp, &st.selected_keys);
|
||||
let retained = selected.len();
|
||||
let l = super::layer_mut(proj, cid, lid)?;
|
||||
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.paste", "no property"))?;
|
||||
let mut sel = vec![];
|
||||
@@ -217,8 +232,10 @@ pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, L
|
||||
// Re-timing keeps the keys' order, so the pasted keys are found again by index.
|
||||
let pasted: Vec<usize> = pr.keys.iter().enumerate().filter(|(_, k)| sel.contains(&k.time)).map(|(i, _)| i).collect();
|
||||
retime_roving(&mut pr.keys, pr.spatial);
|
||||
st.selected_keys = pasted.iter().filter_map(|&i| pr.keys.get(i)).map(|k| KeyRef { layer: lid, prop: uid, time: k.time }).collect();
|
||||
Ok(json!(st.selected_keys.len()))
|
||||
selected.extend(pasted.iter().filter_map(|&i| pr.keys.get(i)).map(|k| KeyRef { layer: lid, prop: uid, time: k.time }));
|
||||
let count = selected.len().saturating_sub(retained);
|
||||
st.selected_keys = selected;
|
||||
Ok(json!(count))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -297,6 +314,9 @@ fn set_key(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let base = KeyTimeBase::from_params(p, "keys.set")?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
if layer.switches.locked {
|
||||
return Err(bad("keys.set", "the layer is locked"));
|
||||
}
|
||||
let t = key_time(layer, comp.frame_rate, p, "time", base, "keys.set", false)?.ok_or_else(|| bad("keys.set", "missing `time` (layer time by default)"))?;
|
||||
let new_t = key_time(layer, comp.frame_rate, p, "newTime", base, "keys.set", true)?;
|
||||
let value = p.get("value").cloned();
|
||||
@@ -350,6 +370,9 @@ fn set_ease(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let influence = f_p(p, "influence").map(|v| (v / 100.0).clamp(0.001, 1.0));
|
||||
s.edit("Keyframe Velocity", merge_p(p), |proj, _| {
|
||||
let l = super::layer_mut(proj, cid, lid)?;
|
||||
if l.switches.locked {
|
||||
return Err(bad("keys.setEase", "the layer is locked"));
|
||||
}
|
||||
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.setEase", "no property"))?;
|
||||
let i = nearest_key(&pr.keys, t).ok_or_else(|| bad("keys.setEase", "no keyframes"))?;
|
||||
let spatial = pr.spatial;
|
||||
@@ -496,7 +519,13 @@ fn time_reverse(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
let mut new_sel = vec![];
|
||||
for ((lid, uid), times) in groups {
|
||||
let Some(pr) = comp.layer_mut(lid).and_then(|l| l.props.find_mut(uid)) else { continue };
|
||||
let Some(layer) = comp.layer_mut(lid) else { continue };
|
||||
// A locked layer's keys stay as they are, and stay selected.
|
||||
if layer.switches.locked {
|
||||
new_sel.extend(times.iter().map(|time| KeyRef { layer: lid, prop: uid, time: *time }));
|
||||
continue;
|
||||
}
|
||||
let Some(pr) = layer.props.find_mut(uid) else { continue };
|
||||
let (mut picked, rest): (Vec<Keyframe>, Vec<Keyframe>) = pr.keys.drain(..).partition(|k| times.contains(&k.time));
|
||||
effectcraft_keyframe::time_reverse(&mut picked);
|
||||
new_sel.extend(picked.iter().map(|k| KeyRef { layer: lid, prop: uid, time: k.time }));
|
||||
|
||||
@@ -213,7 +213,8 @@ fn sample_curve(
|
||||
|
||||
fn grouped_selection(s: &Session) -> std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> {
|
||||
let mut g: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
|
||||
for k in &s.state.selected_keys {
|
||||
let Some(comp) = s.active_comp() else { return g };
|
||||
for k in super::prop::editable_keys(comp, &s.state.selected_keys) {
|
||||
g.entry((k.layer, k.prop)).or_default().push(k.time);
|
||||
}
|
||||
for v in g.values_mut() {
|
||||
@@ -273,7 +274,7 @@ fn run_on_selection(
|
||||
s.edit(label, None, |proj, st| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let fr = comp.frame_rate;
|
||||
let mut sel = vec![];
|
||||
let mut sel = super::anim::locked_selection(comp, &st.selected_keys);
|
||||
for ((lid, uid), times) in groups {
|
||||
let Some(l) = comp.layer_mut(lid) else { continue };
|
||||
let lc = l.clone();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -72,6 +72,10 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut new_sel = vec![];
|
||||
for ((lid, uid), times) in groups {
|
||||
let Some(layer) = comp.layer_mut(lid) else { continue };
|
||||
if layer.switches.locked {
|
||||
new_sel.extend(times.into_iter().map(|time| KeyRef { layer: lid, prop: uid, time }));
|
||||
continue;
|
||||
}
|
||||
let (start, stretch) = (layer.start_time, layer.stretch);
|
||||
let to_comp = |lt: Tick| if (stretch - 100.0).abs() < 1e-9 { start + lt } else { start + Tick((lt.0 as f64 * stretch / 100.0) as i64) };
|
||||
let to_layer = |ct: Tick| {
|
||||
@@ -122,6 +126,9 @@ fn spatial_tangents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let brk = b_p(p, "break").unwrap_or(false);
|
||||
s.edit("Edit Spatial Tangents", merge_p(p), |proj, _| {
|
||||
let l = super::layer_mut(proj, cid, lid)?;
|
||||
if l.switches.locked {
|
||||
return Err(bad("keys.setSpatialTangents", "the layer is locked"));
|
||||
}
|
||||
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.setSpatialTangents", "no property"))?;
|
||||
if !pr.spatial {
|
||||
return Err(bad("keys.setSpatialTangents", "not a spatial property"));
|
||||
|
||||
@@ -37,6 +37,8 @@ pub(crate) fn label_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("keys.label", "select keyframes first"));
|
||||
}
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let keys = super::prop::editable_keys(comp, &keys);
|
||||
let n = s.edit("Keyframe Label", None, |proj, _| {
|
||||
let mut n = 0;
|
||||
for k in &keys {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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(×) {
|
||||
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
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,80 @@ fn select(s: &mut crate::Session, layer: u64, path: &str) {
|
||||
s.execute("prop.select", json!({"layer": layer, "path": path})).unwrap();
|
||||
}
|
||||
|
||||
fn selection_command_skips_locked_keys(command: &str, path: &str, values: [serde_json::Value; 3], mut params: serde_json::Value) {
|
||||
let (mut s, locked) = setup();
|
||||
let unlocked = s.execute("layer.newSolid", json!({"name": "Editable", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
|
||||
for layer in [locked, unlocked] {
|
||||
animate(&mut s, layer, path, &[(0.0, values[0].clone()), (0.5, values[1].clone()), (1.0, values[2].clone())]);
|
||||
}
|
||||
let before = prop(&s, locked, path).keys;
|
||||
let selected: Vec<_> = [locked, unlocked]
|
||||
.into_iter()
|
||||
.flat_map(|layer| {
|
||||
let pr = prop(&s, layer, path);
|
||||
pr.keys.into_iter().map(move |key| crate::KeyRef { layer: effectcraft_project::LayerId(layer), prop: pr.uid, time: key.time })
|
||||
})
|
||||
.collect();
|
||||
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": true})).unwrap();
|
||||
s.state.selected_keys = selected.clone();
|
||||
if params.get("keys").is_some() {
|
||||
params["keys"] = serde_json::to_value(&selected).unwrap();
|
||||
}
|
||||
s.execute_checked(command, params.clone()).unwrap();
|
||||
assert_eq!(prop(&s, locked, path).keys, before, "{command} changed a locked layer");
|
||||
let after = prop(&s, unlocked, path).keys;
|
||||
assert_ne!(after, before, "{command} must still edit unlocked keys");
|
||||
let after_selection = s.state.selected_keys.clone();
|
||||
assert!(selected.iter().filter(|k| k.layer.0 == locked).all(|key| after_selection.contains(key)));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(prop(&s, unlocked, path).keys, before);
|
||||
assert!(selected.iter().filter(|key| key.layer.0 == locked).all(|key| s.state.selected_keys.contains(key)));
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(prop(&s, unlocked, path).keys, after);
|
||||
assert_eq!(s.state.selected_keys, after_selection);
|
||||
// All-locked input must preserve the selection and document as well.
|
||||
s.state.selected_keys.retain(|k| k.layer.0 == locked);
|
||||
if params.get("keys").is_some() {
|
||||
params["keys"] = serde_json::to_value(&s.state.selected_keys).unwrap();
|
||||
}
|
||||
let locked_selection = s.state.selected_keys.clone();
|
||||
let project = serde_json::to_value(&*s.project).unwrap();
|
||||
let _ = s.execute_checked(command, params);
|
||||
assert_eq!(serde_json::to_value(&*s.project).unwrap(), project);
|
||||
assert_eq!(s.state.selected_keys, locked_selection);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiggler_preserves_locked_keys_in_mixed_selection() {
|
||||
selection_command_skips_locked_keys(
|
||||
"keys.wiggle",
|
||||
"transform/opacity",
|
||||
[json!(10), json!(90), json!(20)],
|
||||
json!({"apply": "temporal", "frequency": 8, "magnitude": 10}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoother_preserves_locked_keys_in_mixed_selection() {
|
||||
selection_command_skips_locked_keys("keys.smooth", "transform/opacity", [json!(10), json!(50), json!(90)], json!({"tolerance": 1}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exponential_scale_preserves_locked_keys_in_mixed_selection() {
|
||||
selection_command_skips_locked_keys(
|
||||
"keys.exponentialScale",
|
||||
"transform/scale",
|
||||
[json!([100, 100, 100]), json!([150, 150, 150]), json!([400, 400, 400])],
|
||||
json!({}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_label_preserves_locked_keys_in_mixed_selection() {
|
||||
selection_command_skips_locked_keys("edit.label", "transform/opacity", [json!(10), json!(50), json!(90)], json!({"label": "Red"}));
|
||||
selection_command_skips_locked_keys("edit.label", "transform/opacity", [json!(10), json!(50), json!(90)], json!({"label": "Red", "keys": true}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiggle_adds_keys_at_frequency_within_magnitude() {
|
||||
let (mut s, l) = setup();
|
||||
|
||||
@@ -43,3 +43,207 @@ fn delete_keys_reports_zero_when_no_selected_key_exists() {
|
||||
assert_eq!(removed, json!(0));
|
||||
assert_eq!(keys_of(&s, layer, prop), keys);
|
||||
}
|
||||
|
||||
/// A layer locked after its keys were selected keeps them: Delete and Edit Value leave them alone.
|
||||
#[test]
|
||||
fn a_locked_layers_keys_cannot_be_deleted_or_edited() {
|
||||
let (mut s, layer, prop) = setup();
|
||||
let keys = keys_of(&s, layer, prop);
|
||||
s.state.selected_keys = keys.clone();
|
||||
s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap();
|
||||
let removed = s.execute_checked("keys.delete", json!({})).unwrap();
|
||||
assert_eq!(removed, json!(0));
|
||||
assert_eq!(keys_of(&s, layer, prop), keys);
|
||||
assert_eq!(s.state.selected_keys, keys, "the selection stays");
|
||||
let e = s.execute("keys.set", json!({"layer": layer, "prop": prop, "time": 0, "value": 90})).unwrap_err().to_string();
|
||||
assert!(e.contains("locked"), "{e}");
|
||||
let value = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys[0].value.as_f64();
|
||||
assert_eq!(value, 0.0);
|
||||
// Unlocked again, both work.
|
||||
s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap();
|
||||
s.execute_checked("keys.set", json!({"layer": layer, "prop": prop, "time": 0, "value": 90})).unwrap();
|
||||
assert_eq!(s.execute_checked("keys.delete", json!({})).unwrap(), json!(3));
|
||||
}
|
||||
|
||||
/// Easy Ease, Toggle Hold and Time-Reverse leave a locked layer's keys as they are and keep them
|
||||
/// selected; unlocked, they change them.
|
||||
#[test]
|
||||
fn selection_based_key_edits_skip_a_locked_layer() {
|
||||
let (mut s, layer, prop) = setup();
|
||||
let keys = keys_of(&s, layer, prop);
|
||||
let stored = |s: &Session| s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys.clone();
|
||||
let before = stored(&s);
|
||||
s.state.selected_keys = keys.clone();
|
||||
s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap();
|
||||
for command in ["keys.easyEase", "keys.toggleHold", "keys.timeReverse"] {
|
||||
s.execute_checked(command, json!({})).unwrap();
|
||||
assert_eq!(stored(&s), before, "{command} changed a locked layer's keys");
|
||||
assert_eq!(s.state.selected_keys, keys, "{command} dropped the selection of a locked layer's keys");
|
||||
}
|
||||
s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap();
|
||||
s.execute_checked("keys.easyEase", json!({})).unwrap();
|
||||
assert_ne!(stored(&s), before, "unlocked, Easy Ease changes the keys");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spatial_handles_cannot_edit_a_locked_layer() {
|
||||
let (mut s, layer, _) = setup();
|
||||
for (time, value) in [(0, [10, 20, 0]), (1, [80, 60, 0])] {
|
||||
s.execute("prop.addKey", json!({"layer": layer, "path": "transform/position", "time": time, "value": value})).unwrap();
|
||||
}
|
||||
let prop = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/position").unwrap().uid;
|
||||
s.execute("keys.select", json!({"keys": [{"layer": layer, "prop": prop, "time": 0}]})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap();
|
||||
let before = s.project.to_json();
|
||||
let selected = s.state.selected_keys.clone();
|
||||
let undo_len = s.history.undo.len();
|
||||
let params = json!({"layer": layer, "prop": prop, "time": 0, "out": [15, 25, 0]});
|
||||
let error = s.execute("keys.setSpatialTangents", params.clone()).unwrap_err().to_string();
|
||||
assert!(error.contains("locked"), "{error}");
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
assert_eq!(s.state.selected_keys, selected);
|
||||
assert_eq!(s.history.undo.len(), undo_len);
|
||||
s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap();
|
||||
let unlocked = s.project.to_json();
|
||||
s.execute("keys.setSpatialTangents", params).unwrap();
|
||||
let edited = s.project.to_json();
|
||||
assert_ne!(edited, unlocked);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), unlocked);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), edited);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_keys_or_motion_paths_cannot_edit_locked_targets() {
|
||||
for path_clip in [false, true] {
|
||||
for explicit in [false, true] {
|
||||
let (mut s, layer, opacity) = setup();
|
||||
let target_path = if path_clip { "transform/position" } else { "transform/opacity" };
|
||||
let source = if path_clip {
|
||||
s.execute("mask.new", json!({"layer": layer, "vertices": [[0, 0], [20, 10], [40, 0]], "closed": false})).unwrap();
|
||||
s.execute("prop.toggleAnimation", json!({"layer": layer, "path": "masks/#1/path"})).unwrap();
|
||||
s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("masks/#1/path").unwrap().uid
|
||||
} else {
|
||||
opacity
|
||||
};
|
||||
s.execute("keys.select", json!({"keys": [{"layer": layer, "prop": source, "time": 0}]})).unwrap();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": layer, "path": target_path})).unwrap();
|
||||
s.execute("time.set", json!({"time": 3})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap();
|
||||
let params = if explicit { json!({"layer": layer, "path": target_path}) } else { json!({}) };
|
||||
let before = s.project.to_json();
|
||||
let selected = s.state.selected_keys.clone();
|
||||
let undo_len = s.history.undo.len();
|
||||
let result = s.execute("keys.paste", params.clone());
|
||||
assert!(result.is_err(), "path={path_clip}, explicit={explicit}: {result:?}");
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
assert_eq!(s.state.selected_keys, selected);
|
||||
assert_eq!(s.history.undo.len(), undo_len);
|
||||
s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap();
|
||||
let unlocked = s.project.to_json();
|
||||
s.execute("keys.paste", params).unwrap();
|
||||
let pasted = s.project.to_json();
|
||||
assert_ne!(pasted, unlocked);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), unlocked);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), pasted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_paste_skips_locked_targets_without_changing_layer_pairing() {
|
||||
let (mut s, locked, locked_prop) = setup();
|
||||
let other = s.execute("layer.newSolid", json!({"name": "Other", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
for (time, value) in [(0, 100), (1, 80), (2, 60)] {
|
||||
s.execute("prop.addKey", json!({"layer": other, "path": "transform/opacity", "time": time, "value": value})).unwrap();
|
||||
}
|
||||
let other_prop = s.active_comp().unwrap().layer(LayerId(other)).unwrap().props.prop("transform/opacity").unwrap().uid;
|
||||
let locked_keys = keys_of(&s, locked, locked_prop);
|
||||
s.state.selected_keys = locked_keys.iter().chain(keys_of(&s, other, other_prop).iter()).copied().collect();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": true})).unwrap();
|
||||
s.execute("time.set", json!({"time": 3})).unwrap();
|
||||
let before = s.project.to_json();
|
||||
assert_eq!(s.execute("keys.paste", json!({"layers": [locked, other]})).unwrap(), json!(3), "only newly pasted keys are counted");
|
||||
assert_eq!(keys_of(&s, locked, locked_prop), locked_keys);
|
||||
assert!(locked_keys.iter().all(|k| s.state.selected_keys.contains(k)));
|
||||
assert_eq!(s.state.selected_keys.len(), 6);
|
||||
let keys = &s.active_comp().unwrap().layer(LayerId(other)).unwrap().props.find(other_prop).unwrap().keys;
|
||||
assert_eq!(keys.iter().filter(|k| k.time.seconds() >= 3.0).map(|k| k.value.as_f64()).collect::<Vec<_>>(), [100.0, 80.0, 60.0]);
|
||||
let pasted = s.project.to_json();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), pasted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_path_paste_preserves_locked_source_selection() {
|
||||
for explicit in [false, true] {
|
||||
let (mut s, source, _) = setup();
|
||||
s.execute("mask.new", json!({"layer": source, "vertices": [[0, 0], [20, 10], [40, 0]], "closed": false})).unwrap();
|
||||
s.execute("prop.toggleAnimation", json!({"layer": source, "path": "masks/#1/path"})).unwrap();
|
||||
let uid = s.active_comp().unwrap().layer(LayerId(source)).unwrap().props.prop("masks/#1/path").unwrap().uid;
|
||||
s.execute("keys.select", json!({"keys": [{"layer": source, "prop": uid, "time": 0}]})).unwrap();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
let locked_keys = s.state.selected_keys.clone();
|
||||
let target = s.execute("layer.newSolid", json!({"name": "Target", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.select", json!({"layer": target, "path": "transform/position"})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [source], "switch": "locked", "value": true})).unwrap();
|
||||
s.state.selected_keys = locked_keys.clone();
|
||||
let params = if explicit { json!({"layer": target, "path": "transform/position"}) } else { json!({}) };
|
||||
let before = s.project.to_json();
|
||||
assert_eq!(s.execute("keys.paste", params).unwrap(), json!(3));
|
||||
assert!(locked_keys.iter().all(|k| s.state.selected_keys.contains(k)));
|
||||
assert_eq!(s.state.selected_keys.len(), 4);
|
||||
assert_eq!(s.state.selected_keys.iter().filter(|k| k.layer == LayerId(target)).count(), 3);
|
||||
let pasted = s.project.to_json();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), pasted);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_transform_preserves_locked_keys_in_a_mixed_selection() {
|
||||
let (mut s, locked, locked_prop) = setup();
|
||||
let unlocked = s.execute("layer.newSolid", json!({"name": "Unlocked", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
for t in 0..3 {
|
||||
s.execute("prop.addKey", json!({"layer": unlocked, "path": "transform/opacity", "time": t, "value": t * 25})).unwrap();
|
||||
}
|
||||
let unlocked_prop = s.active_comp().unwrap().layer(LayerId(unlocked)).unwrap().props.prop("transform/opacity").unwrap().uid;
|
||||
let locked_keys = keys_of(&s, locked, locked_prop);
|
||||
s.state.selected_keys = locked_keys.iter().chain(keys_of(&s, unlocked, unlocked_prop).iter()).copied().collect();
|
||||
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": true})).unwrap();
|
||||
let stored = |s: &Session, layer, prop| s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys.clone();
|
||||
let before = stored(&s, locked, locked_prop);
|
||||
let undo = s.history.undo.len();
|
||||
for offset in [0.5, 1.0] {
|
||||
s.execute("keys.transform", json!({"timeOffset": offset, "valueOffset": offset * 10.0, "merge": "mixed-transform", "fromStart": true})).unwrap();
|
||||
assert_eq!(stored(&s, locked, locked_prop), before);
|
||||
assert_eq!(s.state.selected_keys.len(), 6);
|
||||
assert!(locked_keys.iter().all(|k| s.state.selected_keys.contains(k)), "locked selections stay at their original times");
|
||||
assert!(keys_of(&s, unlocked, unlocked_prop).iter().all(|k| s.state.selected_keys.contains(k)), "unlocked selections follow the keys");
|
||||
let moved = stored(&s, unlocked, unlocked_prop);
|
||||
for (i, key) in moved.iter().enumerate() {
|
||||
assert_eq!(key.time.seconds(), i as f64 + offset);
|
||||
assert_eq!(key.value.as_f64(), i as f64 * 25.0 + offset * 10.0);
|
||||
}
|
||||
}
|
||||
assert_eq!(s.history.undo.len(), undo + 1, "one merged drag");
|
||||
let after = stored(&s, unlocked, unlocked_prop);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(stored(&s, locked, locked_prop), before);
|
||||
assert_eq!(stored(&s, unlocked, unlocked_prop), before);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(stored(&s, locked, locked_prop), before);
|
||||
assert_eq!(stored(&s, unlocked, unlocked_prop), after);
|
||||
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": false})).unwrap();
|
||||
s.execute("keys.transform", json!({"timeOffset": 0.5, "valueOffset": 10})).unwrap();
|
||||
assert_ne!(stored(&s, locked, locked_prop), before, "unlocking makes the keys editable again");
|
||||
}
|
||||
@@ -184,6 +184,51 @@ fn mask_interpolation_creates_matching_keys() {
|
||||
assert!(s.execute_checked("mask.interpolate", json!({"matchingMethod": "magic", "times": [0, 0.8], "layer": clip.0})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_interpolation_skips_locked_selection_and_rejects_explicit_locked_targets() {
|
||||
let (mut s, locked, _) = setup(patch_frames(rotating_pose));
|
||||
let unlocked = effectcraft_project::LayerId(s.execute("layer.newSolid", json!({"width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap());
|
||||
let a = ShapePath::rect([20.0, 20.0], 10.0, 10.0);
|
||||
let b = ShapePath::rect([40.0, 30.0], 20.0, 20.0);
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let mut selected = vec![];
|
||||
let mut masks = vec![];
|
||||
for layer in [locked, unlocked] {
|
||||
let uid = new_mask(&mut s, layer, &a);
|
||||
masks.push(uid);
|
||||
s.edit("keys", None, |project, _| {
|
||||
let pr = project.comp_mut(cid).unwrap().layer_mut(layer).unwrap().props.find_group_mut(uid).unwrap().get_mut("path").unwrap();
|
||||
pr.keys = vec![Keyframe::new(frame_time(0), KV::Path(a.clone())), Keyframe::new(frame_time(20), KV::Path(b.clone()))];
|
||||
selected.extend(pr.keys.iter().map(|key| crate::KeyRef { layer, prop: pr.uid, time: key.time }));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
s.execute("layer.setSwitch", json!({"layers": [locked.0], "switch": "locked", "value": true})).unwrap();
|
||||
s.state.selected_keys = selected.clone();
|
||||
let before = mask_path(&s, locked, masks[0]).keys;
|
||||
let options = serde_json::to_value(&s.state.mask_interp).unwrap();
|
||||
let project = serde_json::to_value(&*s.project).unwrap();
|
||||
let error = s.execute_checked("mask.interpolate", json!({"layer": locked.0, "mask": masks[0], "times": [0.0, 0.8], "quality": 75})).unwrap_err();
|
||||
assert!(error.to_string().contains("locked"), "{error}");
|
||||
assert_eq!(serde_json::to_value(&*s.project).unwrap(), project);
|
||||
assert_eq!(serde_json::to_value(&s.state.mask_interp).unwrap(), options);
|
||||
assert_eq!(s.state.selected_keys, selected);
|
||||
s.execute_checked("mask.interpolate", json!({})).unwrap();
|
||||
assert_eq!(mask_path(&s, locked, masks[0]).keys, before);
|
||||
assert_eq!(mask_path(&s, unlocked, masks[1]).keys.len(), 21);
|
||||
assert_eq!(s.state.selected_keys, selected);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(mask_path(&s, unlocked, masks[1]).keys.len(), 2);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(mask_path(&s, unlocked, masks[1]).keys.len(), 21);
|
||||
s.state.selected_keys.retain(|key| key.layer == locked);
|
||||
let selection = s.state.selected_keys.clone();
|
||||
assert!(s.execute_checked("mask.interpolate", json!({})).is_err());
|
||||
assert_eq!(mask_path(&s, locked, masks[0]).keys, before);
|
||||
assert_eq!(s.state.selected_keys, selection);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Warp Stabilizer
|
||||
|
||||
/// Camera offset on frame `f`: a slow pan plus jitter.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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", "出力をシミュレート"),
|
||||
|
||||
@@ -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", "トランスフォームをリセット"),
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -125,6 +125,9 @@ pub(crate) fn entry_enabled(app: &EffectcraftApp, e: &MenuEntry) -> bool {
|
||||
if e.command == "edit.clear" && app.ui.focused == PanelKind::RenderQueue {
|
||||
return !app.session.is_rendering() && !app.session.project.render_queue.is_empty();
|
||||
}
|
||||
if e.command == "file.close" && super::invoke::no_params(&e.params) && app.ui.focused == PanelKind::RenderQueue {
|
||||
return !app.ui.dock.locked;
|
||||
}
|
||||
if e.command == "window.panel" {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ fn vertex_delta(app: &EffectcraftApp, layer: effectcraft_engine::project::LayerI
|
||||
})
|
||||
}
|
||||
|
||||
fn no_params(p: &Value) -> bool {
|
||||
pub(super) fn no_params(p: &Value) -> bool {
|
||||
p.as_object().is_none_or(|m| m.is_empty())
|
||||
}
|
||||
|
||||
@@ -223,6 +223,21 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Close follows panel focus too: with the Render Queue active, Cmd+W closes that panel and
|
||||
// leaves the active composition open (it used to close the composition next to it).
|
||||
if id == "file.close" && no_params(¶ms) && app.ui.focused == PanelKind::RenderQueue {
|
||||
app.apply_dock(craft_ui::docking::Action::Close { panel: PanelKind::RenderQueue })?;
|
||||
// The focus goes to a panel that is still there, so the next Close acts on the composition
|
||||
// instead of on the queue that is gone.
|
||||
let next = [PanelKind::Timeline, PanelKind::Composition, PanelKind::Project].into_iter().find(|p| panel_shown(app, *p)).unwrap_or(PanelKind::Project);
|
||||
app.show_panel(next);
|
||||
app.focus_panel(ctx, next);
|
||||
// Activating a passive viewer emits OpenComp, which raises Timeline. Finish that routing
|
||||
// before restoring the destination panel, including when they share the same tab group.
|
||||
app.handle_events(ctx);
|
||||
app.show_panel(next);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
// Edit ▸ Paste with SVG markup on the system clipboard pastes it as a shape layer (After
|
||||
// Effects 26.3). Copying in the app replaces the system clipboard with a note naming what
|
||||
// was copied, so SVG there was copied after anything copied in the app.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:"));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -1341,7 +1341,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, ¶ms, lbl))).clicked() {
|
||||
if !selected {
|
||||
actions.push(("keys.select".into(), json!({"keys": [this_key.clone()]})));
|
||||
}
|
||||
actions.push((cmd.into(), params));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a key drag (or Alt-scale).
|
||||
@@ -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, ©));
|
||||
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();
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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!({}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
@@ -75,7 +76,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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user