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

This commit is contained in:
bflatastic
2026-10-30 14:04:46 +07:00
109 changed files with 4958 additions and 519 deletions
Generated
+3 -2
View File
@@ -1121,13 +1121,14 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
dependencies = [
"chrono",
"dunce",
"egui",
"flate2",
"getrandom 0.3.4",
"image",
"log",
"plist",
"semver",
@@ -4293,7 +4294,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.45.0",
]
[[package]]
+2 -1
View File
@@ -14,7 +14,8 @@ repository = "https://github.com/storytold/effectcraft"
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["read"] }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-quit = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] }
# internal
effectcraft-time = { path = "crates/time" }
effectcraft-geom = { path = "crates/geom" }
+1 -1
View File
@@ -155,7 +155,7 @@ fn install_file_dialogs(app: &mut EffectcraftApp) -> Option<std::sync::mpsc::Rec
fn main() -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::EFFECTCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Some(result) = craft_update::self_update::finish(&effectcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
// First, so the panic hook and every start-up warning are recorded. Records go to stderr
+3
View File
@@ -114,3 +114,6 @@ sync_github_name = false
[people.ashokram-si]
real_name = "Ashok Ram"
[people.dylanmtaylor]
sync_github_name = true
+6 -7
View File
@@ -4,7 +4,7 @@ use effectcraft_engine::Session;
use effectcraft_engine::time::Tick;
use serde_json::{Value, json};
use crate::confine::Confinement;
use crate::confine::{Confinement, Lookup};
use crate::{BridgeClient, Error, Result, base64, encode_png, recompress_png};
pub struct Backend {
@@ -63,12 +63,11 @@ impl Backend {
pub fn exec(&mut self, id: &str, params: Value) -> Result<Value> {
let params = if params.is_null() { json!({}) } else { params };
if let Some(c) = self.confinement.clone() {
let lookup = match c.check_command(id, &params)? {
Some(l @ (crate::confine::Lookup::ProjectFile | crate::confine::Lookup::QueueDefault)) => Some((l, self.run("project.summary", json!({}))?)),
Some(crate::confine::Lookup::RenderOutputs) => Some((crate::confine::Lookup::RenderOutputs, self.run("renderQueue.list", json!({}))?)),
None => None,
};
if let Some((lookup, found)) = lookup {
for lookup in c.check_command(id, &params)? {
let found = match lookup {
Lookup::ProjectFile | Lookup::QueueDefault => self.run("project.summary", json!({}))?,
Lookup::RenderOutputs => self.run("renderQueue.list", json!({}))?,
};
c.check_lookup(&lookup, &found)?;
}
}
+30 -15
View File
@@ -6,10 +6,11 @@
//! `engine.batch`) is checked before it runs: its file parameters must be absolute paths inside
//! the roots (symbolic links resolved, no `..`), any other absolute path in its parameters too,
//! and commands that write to a file they are not given (Save, Increment and Save, rendering the
//! Render Queue) are checked against where they would write. Commands that run code or open
//! other applications (scripts, ScriptUI panels and the events that reach them, Execute File,
//! Reveal in Finder…) are refused while confined, as is granting scripts the file and network
//! access the user withheld.
//! Render Queue) are checked against where they would write. Queueing a render without naming
//! its output needs a saved project, because the default output of an unsaved one is the home
//! folder. Commands that run code or open other applications (scripts, ScriptUI panels and the
//! events that reach them, Execute File, Reveal in Finder…) are refused while confined, as is
//! granting scripts the file and network access the user withheld.
use std::path::{Component, Path, PathBuf};
@@ -121,6 +122,9 @@ const WRITES: &[(&str, &[&str])] = &[
/// Commands that write where they are not told: the project's file, the Render Queue's outputs.
const IMPLICIT_WRITES: &[&str] = &["file.save", "file.incrementAndSave", "renderQueue.render", "render.preRender"];
/// Commands that queue a render, whose output defaults to the project's folder when not given.
const QUEUES_AN_OUTPUT: &[&str] = &["renderQueue.add", "render.preRender", "file.createProxy"];
/// What must be looked up before an implicitly writing command may run.
#[derive(Debug, PartialEq, Eq)]
pub enum Lookup {
@@ -128,8 +132,9 @@ 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 Queue item
/// added without `output` writes beside it, or to the home folder before the first save.
/// 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,
}
@@ -169,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)")));
}
@@ -192,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),
@@ -205,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;
@@ -217,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"))),
@@ -242,14 +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")));
}
// Without `output`, an unsaved project's item would write to the home folder (#2479).
if id == "renderQueue.add" && !given {
return Ok(Some(Lookup::QueueDefault));
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);
}
if IMPLICIT_WRITES.contains(&id) && !(given && id == "file.save") {
return Ok(Some(if id == "file.save" || id == "file.incrementAndSave" { Lookup::ProjectFile } else { Lookup::RenderOutputs }));
lookups.push(if id == "file.save" || id == "file.incrementAndSave" { Lookup::ProjectFile } else { Lookup::RenderOutputs });
}
Ok(None)
Ok(lookups)
}
/// Check what a [`Lookup`] found: `project.summary` or `renderQueue.list`'s result.
@@ -264,7 +279,7 @@ impl Confinement {
Lookup::QueueDefault => match found.get("path").and_then(Value::as_str) {
Some(_) => Ok(()),
None => Err(Error::BadArgs(
"`renderQueue.add`: the project has no file yet, so the default output would go to the home folder: give `output` inside an automation write root".into(),
"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 => {
+54 -1
View File
@@ -1479,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
@@ -1546,7 +1599,7 @@ fn blender_export_requires_an_absolute_path_in_a_write_root() {
}
let confinement = Confinement::new(std::slice::from_ref(&read), std::slice::from_ref(&write)).unwrap();
let check = |path: &std::path::Path| confinement.check_command("camera.exportBlender", &json!({"path": path}));
assert_eq!(check(&write.join("scene.py")).unwrap(), None);
assert!(check(&write.join("scene.py")).unwrap().is_empty());
for path in [read.join("scene.py"), base.join("outside.py"), write.join("../read/scene.py")] {
assert!(check(&path).is_err(), "must deny {}", path.display());
}
+16
View File
@@ -362,6 +362,22 @@ mod tests {
assert!(a > 0.99 && c[0] > 0.5, "{c:?}");
}
/// #2102: negative Choke Matte keeps growing the matte past -20, down to -100: a 120 px hole
/// in an opaque frame closes a bit more with every step.
#[test]
fn simple_choker_grows_the_matte_at_large_negative_values() {
let mut img = Image::filled(200, 200, [1.0, 1.0, 1.0, 1.0]);
for y in 40..160 {
for x in 40..160 {
img.set(x, y, [0.0; 4]);
}
}
let filled = |c: f64| alpha_sum(&run("ec.matte.simplechoker", &[("chokeMatte", num(c))], img.clone()));
let sums: Vec<f32> = [-10.0, -20.0, -40.0, -60.0].into_iter().map(filled).collect();
assert!(sums.windows(2).all(|w| w[1] > w[0] + 100.0), "{sums:?}");
assert!((filled(-100.0) - 200.0 * 200.0).abs() < 1.0, "-100 closes the hole");
}
#[test]
fn matte_choker_defaults_shrink() {
let img = square();
+1 -1
View File
@@ -164,7 +164,7 @@ pub use enabled::always;
pub(crate) use enabled::{has_comp, has_keys, has_layers, has_project_selection, not_yet, not_yet_run};
pub use params::match_path_of;
pub(crate) use params::{
b_p, bad, comp_id, f_p, frontend, layer_mut, layer_p, layers_p, merge_p, popup_options, resolve_effect_path, resolve_layer, selectable,
b_p, bad, comp_id, f_p, frontend, layer_mut, layer_p, layers_or, layers_p, merge_p, popup_options, resolve_effect_path, resolve_layer, selectable,
selected_leaf_props, str_p, time_p, unlocked, visible_p,
};
pub use schema::{accepted_params, check_params, params_schema};
+35 -6
View File
@@ -56,6 +56,10 @@ fn compatible(a: &Property, b: &effectcraft_keyframe::Value) -> bool {
a.value.kind_name() == b.kind_name() && a.value.components().len() == b.components().len()
}
pub(crate) fn locked_selection(comp: &effectcraft_project::Comp, selected: &[KeyRef]) -> Vec<KeyRef> {
selected.iter().filter(|k| comp.layer(k.layer).is_some_and(|l| l.switches.locked)).copied().collect()
}
/// Paste copied keyframes at the current time into the target layers' properties.
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
let clip = s.state.key_clipboard.clone();
@@ -90,6 +94,9 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
let mut plan: Vec<(LayerId, Uid, usize)> = vec![];
for (ti, lid) in targets.iter().enumerate() {
let Some(l) = comp.layer(*lid) else { continue };
if l.switches.locked {
continue;
}
for (ci, c) in clip.iter().enumerate() {
if paired && sources.get(ti) != Some(&c.layer) {
continue;
@@ -111,11 +118,12 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
}
}
if plan.is_empty() {
return Err(bad("keys.paste", "no compatible property to paste into (select a property of the same type)"));
return Err(bad("keys.paste", "no compatible unlocked property to paste into (unlock the target or select a property of the same type)"));
}
s.edit("Paste Keyframes", merge_p(p), |proj, st| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let mut sel = vec![];
let mut sel = locked_selection(comp, &st.selected_keys);
let retained = sel.len();
for (lid, uid, ci) in &plan {
let Some(l) = comp.layer_mut(*lid) else { continue };
let times: Vec<Tick> = clip[*ci].keys.iter().map(|k| l.layer_time(t + k.time)).collect();
@@ -137,8 +145,9 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
}
retime_roving(&mut pr.keys, pr.spatial);
}
let pasted = sel.len().saturating_sub(retained);
st.selected_keys = sel;
Ok(json!(st.selected_keys.len()))
Ok(json!(pasted))
})
}
@@ -176,6 +185,9 @@ pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, L
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let t = time_p(s, p, Some(comp));
let target = comp.layer(lid).ok_or(EngineError::NoComp)?;
if target.switches.locked {
return Err(bad("keys.paste", "the target layer is locked"));
}
let mut order: Vec<usize> = (0..path.vertices.len()).collect();
if path.closed && !order.is_empty() {
order.push(0);
@@ -203,6 +215,9 @@ pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, L
})
.collect();
s.edit("Paste Keyframes", merge_p(p), |proj, st| {
let comp = proj.comp(cid).ok_or(EngineError::NoComp)?;
let mut selected = locked_selection(comp, &st.selected_keys);
let retained = selected.len();
let l = super::layer_mut(proj, cid, lid)?;
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.paste", "no property"))?;
let mut sel = vec![];
@@ -217,8 +232,10 @@ pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, L
// Re-timing keeps the keys' order, so the pasted keys are found again by index.
let pasted: Vec<usize> = pr.keys.iter().enumerate().filter(|(_, k)| sel.contains(&k.time)).map(|(i, _)| i).collect();
retime_roving(&mut pr.keys, pr.spatial);
st.selected_keys = pasted.iter().filter_map(|&i| pr.keys.get(i)).map(|k| KeyRef { layer: lid, prop: uid, time: k.time }).collect();
Ok(json!(st.selected_keys.len()))
selected.extend(pasted.iter().filter_map(|&i| pr.keys.get(i)).map(|k| KeyRef { layer: lid, prop: uid, time: k.time }));
let count = selected.len().saturating_sub(retained);
st.selected_keys = selected;
Ok(json!(count))
})
}
@@ -297,6 +314,9 @@ fn set_key(s: &mut Session, p: &Value) -> Result<Value> {
let base = KeyTimeBase::from_params(p, "keys.set")?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
if layer.switches.locked {
return Err(bad("keys.set", "the layer is locked"));
}
let t = key_time(layer, comp.frame_rate, p, "time", base, "keys.set", false)?.ok_or_else(|| bad("keys.set", "missing `time` (layer time by default)"))?;
let new_t = key_time(layer, comp.frame_rate, p, "newTime", base, "keys.set", true)?;
let value = p.get("value").cloned();
@@ -350,6 +370,9 @@ fn set_ease(s: &mut Session, p: &Value) -> Result<Value> {
let influence = f_p(p, "influence").map(|v| (v / 100.0).clamp(0.001, 1.0));
s.edit("Keyframe Velocity", merge_p(p), |proj, _| {
let l = super::layer_mut(proj, cid, lid)?;
if l.switches.locked {
return Err(bad("keys.setEase", "the layer is locked"));
}
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.setEase", "no property"))?;
let i = nearest_key(&pr.keys, t).ok_or_else(|| bad("keys.setEase", "no keyframes"))?;
let spatial = pr.spatial;
@@ -496,7 +519,13 @@ fn time_reverse(s: &mut Session, _: &Value) -> Result<Value> {
}
let mut new_sel = vec![];
for ((lid, uid), times) in groups {
let Some(pr) = comp.layer_mut(lid).and_then(|l| l.props.find_mut(uid)) else { continue };
let Some(layer) = comp.layer_mut(lid) else { continue };
// A locked layer's keys stay as they are, and stay selected.
if layer.switches.locked {
new_sel.extend(times.iter().map(|time| KeyRef { layer: lid, prop: uid, time: *time }));
continue;
}
let Some(pr) = layer.props.find_mut(uid) else { continue };
let (mut picked, rest): (Vec<Keyframe>, Vec<Keyframe>) = pr.keys.drain(..).partition(|k| times.contains(&k.time));
effectcraft_keyframe::time_reverse(&mut picked);
new_sel.extend(picked.iter().map(|k| KeyRef { layer: lid, prop: uid, time: k.time }));
+3 -2
View File
@@ -213,7 +213,8 @@ fn sample_curve(
fn grouped_selection(s: &Session) -> std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> {
let mut g: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
for k in &s.state.selected_keys {
let Some(comp) = s.active_comp() else { return g };
for k in super::prop::editable_keys(comp, &s.state.selected_keys) {
g.entry((k.layer, k.prop)).or_default().push(k.time);
}
for v in g.values_mut() {
@@ -273,7 +274,7 @@ fn run_on_selection(
s.edit(label, None, |proj, st| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let fr = comp.frame_rate;
let mut sel = vec![];
let mut sel = super::anim::locked_selection(comp, &st.selected_keys);
for ((lid, uid), times) in groups {
let Some(l) = comp.layer_mut(lid) else { continue };
let lc = l.clone();
+2 -1
View File
@@ -271,7 +271,8 @@ fn add_keyframe(s: &mut Session, _: &Value) -> Result<Value> {
/// Selected keys grouped by (layer, property).
fn keys_by_prop(s: &Session) -> BTreeMap<(LayerId, Uid), Vec<Tick>> {
let mut m: BTreeMap<(LayerId, Uid), Vec<Tick>> = BTreeMap::new();
for k in &s.state.selected_keys {
let Some(comp) = s.active_comp() else { return m };
for k in super::prop::editable_keys(comp, &s.state.selected_keys) {
m.entry((k.layer, k.prop)).or_default().push(k.time);
}
m
+43 -11
View File
@@ -6,7 +6,7 @@ use effectcraft_project::{Comp, Expression, ItemId, ItemKind, Layer, LayerId, La
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, has_comp, has_layers, layers_p, match_path_of, selected_leaf_props, str_p};
use super::{CommandSpec, has_comp, has_layers, layers_or, layers_p, match_path_of, selected_leaf_props, str_p};
use crate::{EngineError, KeyRef, LinkClip, Result, Session, cmd};
fn can_undo(s: &Session) -> std::result::Result<(), String> {
@@ -104,9 +104,16 @@ pub(crate) fn reid(layer: &mut Layer, next: &mut u64) {
*next += 1;
}
/// `edit.duplicate`: copies the targets in place. Without `layers` the targets are, in order:
/// during a text edit the edited layer (the edit is committed first, as `text.endEdit` does),
/// else the selected effects, shape items or text animators, else the selected layers. Layer
/// copies are selected and their ids returned; the other paths return their delegated result.
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
// Effects selected → duplicate them (Edit ▸ Duplicate in Effect Controls).
if p.get("layers").is_none() {
let editing = s.state.text_edit.is_some();
// Effects selected → duplicate them (Edit ▸ Duplicate in Effect Controls). An open text edit
// comes first, as it does for Clear: opening the editor keeps the property selection, but
// while typing, Duplicate means the edited layer, not an effect or item selected before.
if p.get("layers").is_none() && !editing {
let fx = super::effect::selected_effects(s);
if !fx.is_empty() {
let mut out = vec![];
@@ -126,7 +133,17 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
return super::prop_groups::duplicate_contents(s, &items);
}
}
let (cid, ids) = layers_p(s, p)?;
// A deselected layer never keeps an open text edit (#2226): the copies take the selection, so
// commit an open edit first, exactly as `text.endEdit` and Deselect All do — one call on the
// command path so every route (menu, shortcut, palette, MCP, script) ends the edit and leaves
// the same final state. Unnamed, the target is the edited layer even when an engine route
// selected another one meanwhile.
let edited = s.state.text_edit.as_ref().map(|e| e.layer);
super::text_edit::end_edit(s, &Value::Null)?;
let (cid, ids) = match edited {
Some(layer) => layers_or(s, p, &[layer])?,
None => layers_p(s, p)?,
};
let new = s.edit("Duplicate", None, |proj, st| {
let mut next = proj.next_id;
let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?;
@@ -143,14 +160,16 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
}
relink_copies(comp, &map);
proj.next_id = next;
st.selected_layers = created.clone();
// The property, keyframe and mask-vertex selection the edit kept belongs to the originals;
// left in place, the next Duplicate or Clear would act on it, not on the copies. So during
// an edit the copies take the selection the way a click on them would.
if editing {
super::layer::select_only(st, created.clone());
} else {
st.selected_layers = created.clone();
}
Ok(created)
})?;
// Duplicating selects the copies, so an open text edit on a now-deselected layer ends
// (#2226): typing must not keep editing an unselected layer.
if s.state.text_edit.as_ref().is_some_and(|e| !s.state.selected_layers.contains(&e.layer)) {
super::text_edit::end_edit(s, &Value::Null)?;
}
Ok(json!(new.iter().map(|l| l.0).collect::<Vec<_>>()))
}
@@ -968,7 +987,15 @@ pub fn specs() -> Vec<CommandSpec> {
paste_reversed
),
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", layers_or_keys, delete),
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?} (effects or shape items when they are selected)", has_layers, duplicate),
cmd!(
"edit.duplicate",
"Duplicate",
["Edit"],
Some("Cmd+D"),
"{layers?} (without layers: during a text edit the edited layer, else the selected effects, shape items or text animators, else the selected layers)",
layers_or_text_edit,
duplicate
),
cmd!("edit.splitLayer", "Split Layer", ["Edit"], Some("Cmd+Shift+D"), "{layers?}", has_layers, split),
cmd!("edit.liftWorkArea", "Lift Work Area", ["Edit"], None, "{layers?}", has_comp, lift),
cmd!("edit.extractWorkArea", "Extract Work Area", ["Edit"], None, "{layers?}", has_comp, extract),
@@ -1038,6 +1065,11 @@ fn layers_or_keys(s: &Session) -> std::result::Result<(), String> {
if !s.state.selected_keys.is_empty() || !s.state.selected_vertices.is_empty() { super::has_comp(s) } else { has_layers(s) }
}
/// Duplicate's unnamed target: the edited layer during a text edit, else the selection.
fn layers_or_text_edit(s: &Session) -> std::result::Result<(), String> {
if s.state.text_edit.is_some() { super::has_comp(s) } else { has_layers(s) }
}
fn always_ok(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
+7
View File
@@ -72,6 +72,10 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
let mut new_sel = vec![];
for ((lid, uid), times) in groups {
let Some(layer) = comp.layer_mut(lid) else { continue };
if layer.switches.locked {
new_sel.extend(times.into_iter().map(|time| KeyRef { layer: lid, prop: uid, time }));
continue;
}
let (start, stretch) = (layer.start_time, layer.stretch);
let to_comp = |lt: Tick| if (stretch - 100.0).abs() < 1e-9 { start + lt } else { start + Tick((lt.0 as f64 * stretch / 100.0) as i64) };
let to_layer = |ct: Tick| {
@@ -122,6 +126,9 @@ fn spatial_tangents(s: &mut Session, p: &Value) -> Result<Value> {
let brk = b_p(p, "break").unwrap_or(false);
s.edit("Edit Spatial Tangents", merge_p(p), |proj, _| {
let l = super::layer_mut(proj, cid, lid)?;
if l.switches.locked {
return Err(bad("keys.setSpatialTangents", "the layer is locked"));
}
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.setSpatialTangents", "no property"))?;
if !pr.spatial {
return Err(bad("keys.setSpatialTangents", "not a spatial property"));
+2
View File
@@ -37,6 +37,8 @@ pub(crate) fn label_keys(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("keys.label", "select keyframes first"));
}
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let keys = super::prop::editable_keys(comp, &keys);
let n = s.edit("Keyframe Label", None, |proj, _| {
let mut n = 0;
for k in &keys {
+10 -1
View File
@@ -28,6 +28,7 @@ pub(crate) use create::{color_p, index_p, insert_layer, insert_layer_at, place,
pub(crate) use parent::parent_fixes;
use parent::set_parent;
use replace::{has_replace_source, replace_source};
pub(crate) use select::select_only;
use select::{select, select_next, select_prev, select_step};
use switches::{blend, rename, set_comment, set_switch, track_matte};
use timing::{arrange, move_frames, precompose, slip, timing};
@@ -147,7 +148,15 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("layer.moveLater", "Move Layers 1 Frame Later", [], Some("Alt+PageDown"), "{layers?}", has_layers, |s, p| move_frames(s, p, 1)),
cmd!("layer.moveEarlier10", "Move Layers 10 Frames Earlier", [], Some("Alt+Shift+PageUp"), "{layers?}", has_layers, |s, p| move_frames(s, p, -10)),
cmd!("layer.moveLater10", "Move Layers 10 Frames Later", [], Some("Alt+Shift+PageDown"), "{layers?}", has_layers, |s, p| move_frames(s, p, 10)),
cmd!("layer.slip", "Slip Edit", [], None, "{layers?, delta? (seconds) | frames?, merge?}", has_layers, slip),
cmd!(
"layer.slip",
"Slip Edit",
[],
None,
"{layers?, delta? (seconds) | frames?, merge?} → {requested, slipped: [{layer, delta, clamped}]}",
has_layers,
slip
),
cmd!("layer.slipBack", "Slip Edit 1 Frame Earlier", [], None, "{layers?}", has_layers, |s, p| {
let mut p = p.clone();
p["frames"] = json!(-1);
+11 -5
View File
@@ -25,15 +25,21 @@ pub(super) fn select(s: &mut Session, p: &Value) -> Result<Value> {
}
}
} else {
s.state.selected_layers = ids;
s.state.selected_props.clear();
s.state.selected_keys.clear();
let keep = s.state.selected_layers.clone();
s.state.selected_vertices.retain(|v| keep.contains(&v.layer));
select_only(&mut s.state, ids);
}
Ok(json!(s.state.selected_layers.iter().map(|l| l.0).collect::<Vec<_>>()))
}
/// Make `ids` the whole layer selection, as a plain click on them does: no property or keyframe
/// stays selected, and mask vertices stay selected only on layers that are still selected.
pub(crate) fn select_only(st: &mut crate::EditorState, ids: Vec<LayerId>) {
st.selected_layers = ids;
st.selected_props.clear();
st.selected_keys.clear();
let keep = st.selected_layers.clone();
st.selected_vertices.retain(|v| keep.contains(&v.layer));
}
pub(super) fn select_step(s: &mut Session, p: &Value, dir: i64) -> Result<Value> {
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
if comp.layers.is_empty() {
+7 -3
View File
@@ -84,8 +84,9 @@ pub(super) fn move_frames(s: &mut Session, p: &Value, frames: i64) -> Result<Val
/// Slip edit: move a layer's source (footage or precomp) under its fixed in and out points, like
/// dragging the source-duration bar in After Effects. `delta` in seconds or `frames`; positive
/// moves the source later. Clamped so the source still covers the in–out span. Keyframes and
/// layer markers are in layer time, so they travel with the source.
/// moves the source later. Clamped so the source still covers the in–out span: the result gives
/// the `requested` shift and, per layer, the `delta` applied and whether it was `clamped` (#2371).
/// Keyframes and layer markers are in layer time, so they travel with the source.
pub(super) fn slip(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
@@ -130,7 +131,10 @@ pub(super) fn slip(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(())
})?;
Ok(json!({"slipped": shifts.iter().map(|(id, d)| json!({"layer": id.0, "delta": d.seconds()})).collect::<Vec<_>>()}))
Ok(json!({
"requested": delta.seconds(),
"slipped": shifts.iter().map(|(id, d)| json!({"layer": id.0, "delta": d.seconds(), "clamped": *d != delta})).collect::<Vec<_>>(),
}))
}
pub(super) fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
+7 -5
View File
@@ -438,20 +438,22 @@ fn interpolate(s: &mut Session, p: &Value) -> Result<Value> {
// The layer: given, else the layer of selected Mask Path keys, else the selection.
let cid = super::comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let selected = super::prop::editable_keys(comp, &s.state.selected_keys);
let lid = match p.get("layer") {
Some(v) => super::resolve_layer(comp, v).ok_or_else(|| bad(CMD, format!("no layer {v}")))?,
None => match s.state.selected_keys.iter().find(|k| comp.layer(k.layer).is_some_and(|l| l.masks().is_some_and(|m| m.find(k.prop).is_some()))) {
None => match selected.iter().find(|k| comp.layer(k.layer).is_some_and(|l| l.masks().is_some_and(|m| m.find(k.prop).is_some()))) {
Some(k) => k.layer,
None => layer_p(s, p, CMD)?.1,
},
};
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
if !super::prop::key_layer_editable(comp, lid) {
return Err(bad(CMD, "the layer is locked"));
}
let masks = layer.masks().ok_or_else(|| bad(CMD, "the layer has no masks"))?;
let uid = match p.get("mask") {
Some(_) => mask_uid(s, layer, p),
None => s
.state
.selected_keys
None => selected
.iter()
.filter(|k| k.layer == lid)
.find_map(|k| masks.groups().find(|g| g.get("path").is_some_and(|pr| pr.uid == k.prop)).map(|g| g.uid))
@@ -466,7 +468,7 @@ fn interpolate(s: &mut Session, p: &Value) -> Result<Value> {
let ts: Vec<Tick> = a.iter().filter_map(Value::as_f64).map(|t| layer.layer_time(comp.frame_rate.snap_nearest(Tick::from_seconds_f64(t)))).collect();
ts.into_iter().filter_map(|t| path_prop.keys.iter().find(|k| (k.time.0 - t.0).abs() <= comp.frame_duration().0 / 2).map(|k| k.time)).collect()
}
None => s.state.selected_keys.iter().filter(|k| k.layer == lid && k.prop == path_prop.uid).map(|k| k.time).collect(),
None => selected.iter().filter(|k| k.layer == lid && k.prop == path_prop.uid).map(|k| k.time).collect(),
};
times.sort();
times.dedup();
+6 -1
View File
@@ -148,6 +148,11 @@ pub(crate) fn layer_p(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, Laye
/// `layers` param (array) or the selection.
pub(crate) fn layers_p(s: &Session, p: &Value) -> Result<(ItemId, Vec<LayerId>)> {
layers_or(s, p, &s.state.selected_layers)
}
/// `layers` param (array), else `layer`, else those of `fallback` still in the comp.
pub(crate) fn layers_or(s: &Session, p: &Value, fallback: &[LayerId]) -> Result<(ItemId, Vec<LayerId>)> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
// Explicit references that match no layer are an error (agents otherwise get a silent no-op).
@@ -159,7 +164,7 @@ pub(crate) fn layers_p(s: &Session, p: &Value) -> Result<(ItemId, Vec<LayerId>)>
if let Some(v) = p.get("layer").filter(|v| !v.is_null()) {
return Ok((cid, vec![resolve(v)?]));
}
Ok((cid, s.state.selected_layers.iter().copied().filter(|l| comp.layer(*l).is_some()).collect()))
Ok((cid, fallback.iter().copied().filter(|l| comp.layer(*l).is_some()).collect()))
}
/// Whether a layer can be picked by the user (Select All, Ctrl+Up/Down, a Timeline click): not
+66 -10
View File
@@ -247,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![]);
@@ -260,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.
@@ -286,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()
@@ -319,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).
@@ -344,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;
@@ -914,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)?;
@@ -926,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);
@@ -1079,6 +1131,10 @@ fn delete_keys(s: &mut Session, _: &Value) -> Result<Value> {
let mut removed = 0usize;
let mut changed = std::collections::BTreeSet::new();
for k in &sel {
// A layer locked after its keys were selected keeps them (the selection stays too).
if comp.layer(k.layer).is_some_and(|l| l.switches.locked) {
continue;
}
if let Some(pr) = comp.layer_mut(k.layer).and_then(|l| l.props.find_mut(k.prop))
&& let Some(i) = key_at(&pr.keys, k.time)
{
@@ -1092,7 +1148,7 @@ fn delete_keys(s: &mut Session, _: &Value) -> Result<Value> {
}
}
}
st.selected_keys.clear();
st.selected_keys.retain(|k| comp.layer(k.layer).is_some_and(|l| l.switches.locked));
for (layer, prop) in changed {
sync_polystar_property(proj, st, cid, layer, prop, time)?;
}
@@ -1366,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
),
+81 -28
View File
@@ -5,14 +5,16 @@
//! The native `.prproj` format has no public specification and is not read or written.
use std::collections::HashMap;
use std::sync::Arc;
use effectcraft_interchange::{ExportOptions, ImportOptions, PrecompMode, PrerenderMode, Prerendered, TimelineFormat};
use effectcraft_project::render_queue::{AudioOutput, Channels, OutputFormat, OutputModule, ProResProfile, RenderQueueItem, TimeSpan};
use effectcraft_project::{ItemId, LayerId, LayerSource, Project};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, str_p};
use crate::{EngineError, Result, Session, cmd};
use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, str_p};
use crate::jobs::TaskCtl;
use crate::{EngineError, Exporter, Result, Session, cmd};
fn format_p(p: &Value, cmd: &str) -> Result<Option<TimelineFormat>> {
match str_p(p, "format") {
@@ -95,18 +97,34 @@ fn isolate(project: &Project, cid: ItemId, lid: LayerId) -> Option<Project> {
Some(q)
}
/// Render one layer to ProRes 4444 with alpha over its in–out span.
fn prerender(s: &Session, cid: ItemId, lid: LayerId, path: &str) -> std::result::Result<Prerendered, String> {
let exporter = s.exporter.clone().ok_or("export is not available in this build")?;
/// What a pre-render needs from the session, so it can run on a background thread.
struct PrerenderCx {
exporter: Option<Arc<dyn Exporter>>,
footage: Arc<dyn effectcraft_render::FootageSource>,
expr: Option<Arc<dyn effectcraft_render::ExprHost>>,
accel: Option<Arc<dyn effectcraft_render::Accelerator>>,
nested_switches: bool,
}
/// Render one layer to ProRes 4444 with alpha over its in–out span; `progress` as the exporter's.
fn prerender(
cx: &PrerenderCx,
project: &Project,
cid: ItemId,
lid: LayerId,
path: &str,
progress: &mut dyn FnMut(u64, u64) -> bool,
) -> std::result::Result<Prerendered, String> {
let exporter = cx.exporter.as_ref().ok_or("export is not available in this build")?;
if !exporter.formats().contains(&OutputFormat::ProRes) {
return Err("ProRes export is not available in this build".into());
}
let comp = s.project.comp(cid).ok_or("no composition")?;
let comp = project.comp(cid).ok_or("no composition")?;
let l = comp.layer(lid).ok_or("no layer")?;
let fd = comp.frame_duration();
let start = comp.frame_rate.snap(l.in_point.max(effectcraft_time::Tick::ZERO));
let end = l.out_point.min(comp.duration).max(start + fd);
let q = isolate(&s.project, cid, lid).ok_or("no composition")?;
let q = isolate(project, cid, lid).ok_or("no composition")?;
let mut item = RenderQueueItem::new(0, cid);
item.settings.time_span = TimeSpan::Custom { start, end };
item.output = OutputModule::for_format(OutputFormat::ProRes);
@@ -116,16 +134,16 @@ fn prerender(s: &Session, cid: ItemId, lid: LayerId, path: &str) -> std::result:
item.output.output = path.to_string();
let job = crate::ExportJob {
project: &q,
footage: s.footage.as_ref(),
expr: s.expr.as_deref(),
accel: s.accel.as_deref(),
footage: cx.footage.as_ref(),
expr: cx.expr.as_deref(),
accel: cx.accel.as_deref(),
item: &item,
path,
storage: None,
label: format!("Pre-render {}", l.name),
nested_switches: s.prefs.general.switches_affect_nested_comps,
nested_switches: cx.nested_switches,
};
let r = exporter.export(&job, &mut |_, _| true)?;
let r = exporter.export(&job, progress)?;
Ok(Prerendered { path: r.path, width: comp.width, height: comp.height, frame_rate: comp.frame_rate, duration: end - start })
}
@@ -157,33 +175,67 @@ fn export_timeline(s: &mut Session, p: &Value) -> Result<Value> {
let stem = fp.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Timeline".into());
let relative_to = Some(dir.to_string_lossy().to_string()).filter(|d| !d.is_empty());
let opts = ExportOptions { format, prerender: prerender_mode, precomps, relative_to };
// Pre-render what the timeline can't represent.
let project = s.project.clone();
let services = s.services.clone();
let cx = PrerenderCx {
exporter: s.exporter.clone(),
footage: s.footage.clone(),
expr: s.expr.clone(),
accel: s.accel.clone(),
nested_switches: s.prefs.general.switches_affect_nested_comps,
};
// The pre-renders can take minutes: with `background` (the export dialog) they run as a job
// in Window ▸ Progress instead of with the window waiting (#2269).
let wait = !b_p(p, "background").unwrap_or(false);
s.spawn_task("exportTimeline", format!("Export {}", format.label()), wait, move |ctl| {
let (bytes, mut result) = export_timeline_work(&cx, &project, cid, &opts, &dir, &stem, ctl)?;
services.write_file(&path, &bytes).map_err(|e| format!("cannot write {path}: {e}"))?;
result["path"] = json!(path);
result["toast"] = json!(match format {
TimelineFormat::Fcp7Xml => format!("Exported {path} (Final Cut Pro XML; import it in Premiere Pro with File ▸ Import)"),
_ => format!("Exported {path} ({})", format.label()),
});
Ok(Box::new(move |_: &mut Session| Ok(result)))
})
}
/// Pre-render what the timeline can't represent, then build the timeline: its bytes and the
/// command's result (without `path`).
fn export_timeline_work(
cx: &PrerenderCx,
project: &Project,
cid: ItemId,
opts: &ExportOptions,
dir: &std::path::Path,
stem: &str,
ctl: &TaskCtl,
) -> std::result::Result<(Vec<u8>, Value), String> {
let mut pre: HashMap<(ItemId, LayerId), Prerendered> = HashMap::new();
let mut rendered = vec![];
let mut errors = vec![];
for (c, l) in effectcraft_interchange::plan_prerender(&s.project, cid, &opts) {
let comp_name = s.project.item(c).map(|i| i.name.clone()).unwrap_or_default();
let layer_name = s.project.comp(c).and_then(|cc| cc.layer(l)).map(|x| x.name.clone()).unwrap_or_default();
let plan = effectcraft_interchange::plan_prerender(project, cid, opts);
let n = plan.len() as u64;
for (i, (c, l)) in (0u64..).zip(plan) {
let comp_name = project.item(c).map(|it| it.name.clone()).unwrap_or_default();
let layer_name = project.comp(c).and_then(|cc| cc.layer(l)).map(|x| x.name.clone()).unwrap_or_default();
ctl.message(format!("Pre-render {layer_name}"));
let file = dir.join(format!("{stem}_{}_{}_{}.mov", safe(&comp_name), safe(&layer_name), l.0)).to_string_lossy().to_string();
match prerender(s, c, l, &file) {
// Progress over all the layers, each one's frames a share of it.
let mut progress = |done: u64, total: u64| ctl.progress(i * 1000 + done.saturating_mul(1000) / total.max(1), n * 1000);
match prerender(cx, project, c, l, &file, &mut progress) {
Ok(r) => {
rendered.push(r.path.clone());
pre.insert((c, l), r);
}
Err(_) if ctl.cancelled() => return Err(crate::render_queue::CANCELLED.into()),
Err(e) => errors.push(format!("{comp_name} ▸ {layer_name}: {e}")),
}
}
let out = effectcraft_interchange::export(&s.project, cid, &opts, &pre).map_err(|e| EngineError::Other(e.to_string()))?;
s.services.write_file(&path, &out.bytes).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
let out = effectcraft_interchange::export(project, cid, opts, &pre).map_err(|e| e.to_string())?;
let mut warnings = out.warnings.clone();
warnings.extend(errors.iter().map(|e| format!("pre-render failed: {e}")));
s.toast(match format {
TimelineFormat::Fcp7Xml => format!("Exported {path} (Final Cut Pro XML; import it in Premiere Pro with File ▸ Import)"),
_ => format!("Exported {path} ({})", format.label()),
});
Ok(json!({
"path": path,
"format": format.id(),
let result = json!({
"format": opts.format.id(),
"bytes": out.bytes.len(),
"sequences": out.sequences,
"videoTracks": out.video_tracks,
@@ -191,7 +243,8 @@ fn export_timeline(s: &mut Session, p: &Value) -> Result<Value> {
"clips": out.clips,
"prerendered": rendered,
"warnings": warnings,
}))
});
Ok((out.bytes, result))
}
/// The formats, for agents and the export dialog.
@@ -220,7 +273,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Adobe Premiere Pro Project...",
["File", "Export"],
None,
"{comp?, path (Final Cut Pro XML .xml for Premiere Pro or .fcpxml .otio .edl .aaf .omf), format?: xml|fcpxml|otio|edl|aaf|omf, prerender?: none|unsupported|all, precomps?: nest|prerender}",
"{comp?, path (Final Cut Pro XML .xml for Premiere Pro or .fcpxml .otio .edl .aaf .omf), format?: xml|fcpxml|otio|edl|aaf|omf, prerender?: none|unsupported|all, precomps?: nest|prerender, background?: bool (pre-render and write as a job, jobs.list / jobs.wait; returns {job})}",
has_comp,
export_timeline
),
+1 -1
View File
@@ -36,7 +36,7 @@ pub use sections::{
use sections::{Extra, default_labels, lenient_comp_presets};
/// Current settings layout version.
pub const PREFS_VERSION: u32 = 3;
pub const PREFS_VERSION: u32 = 4;
/// Settings file name in the config store.
pub const PREFS_FILE: &str = "prefs.json";
@@ -9,6 +9,8 @@ use super::{Appearance, General, Map, PREFS_VERSION, Prefs, Startup, Value, json
/// `autoSaveMinutes`, `autoSaveVersions`, `labelNames` (array) and `theme` at the top level.
/// - version 2 defaulted the disk cache to 100 GB, more than most laptops have free; the stored
/// old default becomes the new one (a size the user chose is kept).
/// - version 3 and earlier could still hold `createSplitLayersAbove: false`, the default before
/// #2083 (every save kept it); it becomes After Effects' default, above (#2545).
pub fn migrate(mut v: Value) -> Value {
let Some(obj) = v.as_object_mut() else { return json!({}) };
let version = obj.get("version").and_then(Value::as_u64).unwrap_or(0);
@@ -42,6 +44,12 @@ pub fn migrate(mut v: Value) -> Value {
{
d.insert("diskCacheMaxGb".into(), json!(20));
}
if version < 4
&& let Some(g) = obj.get_mut("general").and_then(Value::as_object_mut)
&& g.get("createSplitLayersAbove") == Some(&json!(false))
{
g.insert("createSplitLayersAbove".into(), json!(true));
}
// Settings saved before appearance modes had one theme: keep showing it, as a fixed Dark or
// Light mode, instead of switching established users to Auto.
if let Some(a) = obj.get_mut("appearance").and_then(Value::as_object_mut)
+74
View File
@@ -9,6 +9,80 @@ fn select(s: &mut crate::Session, layer: u64, path: &str) {
s.execute("prop.select", json!({"layer": layer, "path": path})).unwrap();
}
fn selection_command_skips_locked_keys(command: &str, path: &str, values: [serde_json::Value; 3], mut params: serde_json::Value) {
let (mut s, locked) = setup();
let unlocked = s.execute("layer.newSolid", json!({"name": "Editable", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
for layer in [locked, unlocked] {
animate(&mut s, layer, path, &[(0.0, values[0].clone()), (0.5, values[1].clone()), (1.0, values[2].clone())]);
}
let before = prop(&s, locked, path).keys;
let selected: Vec<_> = [locked, unlocked]
.into_iter()
.flat_map(|layer| {
let pr = prop(&s, layer, path);
pr.keys.into_iter().map(move |key| crate::KeyRef { layer: effectcraft_project::LayerId(layer), prop: pr.uid, time: key.time })
})
.collect();
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": true})).unwrap();
s.state.selected_keys = selected.clone();
if params.get("keys").is_some() {
params["keys"] = serde_json::to_value(&selected).unwrap();
}
s.execute_checked(command, params.clone()).unwrap();
assert_eq!(prop(&s, locked, path).keys, before, "{command} changed a locked layer");
let after = prop(&s, unlocked, path).keys;
assert_ne!(after, before, "{command} must still edit unlocked keys");
let after_selection = s.state.selected_keys.clone();
assert!(selected.iter().filter(|k| k.layer.0 == locked).all(|key| after_selection.contains(key)));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(prop(&s, unlocked, path).keys, before);
assert!(selected.iter().filter(|key| key.layer.0 == locked).all(|key| s.state.selected_keys.contains(key)));
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(prop(&s, unlocked, path).keys, after);
assert_eq!(s.state.selected_keys, after_selection);
// All-locked input must preserve the selection and document as well.
s.state.selected_keys.retain(|k| k.layer.0 == locked);
if params.get("keys").is_some() {
params["keys"] = serde_json::to_value(&s.state.selected_keys).unwrap();
}
let locked_selection = s.state.selected_keys.clone();
let project = serde_json::to_value(&*s.project).unwrap();
let _ = s.execute_checked(command, params);
assert_eq!(serde_json::to_value(&*s.project).unwrap(), project);
assert_eq!(s.state.selected_keys, locked_selection);
}
#[test]
fn wiggler_preserves_locked_keys_in_mixed_selection() {
selection_command_skips_locked_keys(
"keys.wiggle",
"transform/opacity",
[json!(10), json!(90), json!(20)],
json!({"apply": "temporal", "frequency": 8, "magnitude": 10}),
);
}
#[test]
fn smoother_preserves_locked_keys_in_mixed_selection() {
selection_command_skips_locked_keys("keys.smooth", "transform/opacity", [json!(10), json!(50), json!(90)], json!({"tolerance": 1}));
}
#[test]
fn exponential_scale_preserves_locked_keys_in_mixed_selection() {
selection_command_skips_locked_keys(
"keys.exponentialScale",
"transform/scale",
[json!([100, 100, 100]), json!([150, 150, 150]), json!([400, 400, 400])],
json!({}),
);
}
#[test]
fn edit_label_preserves_locked_keys_in_mixed_selection() {
selection_command_skips_locked_keys("edit.label", "transform/opacity", [json!(10), json!(50), json!(90)], json!({"label": "Red"}));
selection_command_skips_locked_keys("edit.label", "transform/opacity", [json!(10), json!(50), json!(90)], json!({"label": "Red", "keys": true}));
}
#[test]
fn wiggle_adds_keys_at_frequency_within_magnitude() {
let (mut s, l) = setup();
+204
View File
@@ -43,3 +43,207 @@ fn delete_keys_reports_zero_when_no_selected_key_exists() {
assert_eq!(removed, json!(0));
assert_eq!(keys_of(&s, layer, prop), keys);
}
/// A layer locked after its keys were selected keeps them: Delete and Edit Value leave them alone.
#[test]
fn a_locked_layers_keys_cannot_be_deleted_or_edited() {
let (mut s, layer, prop) = setup();
let keys = keys_of(&s, layer, prop);
s.state.selected_keys = keys.clone();
s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap();
let removed = s.execute_checked("keys.delete", json!({})).unwrap();
assert_eq!(removed, json!(0));
assert_eq!(keys_of(&s, layer, prop), keys);
assert_eq!(s.state.selected_keys, keys, "the selection stays");
let e = s.execute("keys.set", json!({"layer": layer, "prop": prop, "time": 0, "value": 90})).unwrap_err().to_string();
assert!(e.contains("locked"), "{e}");
let value = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys[0].value.as_f64();
assert_eq!(value, 0.0);
// Unlocked again, both work.
s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap();
s.execute_checked("keys.set", json!({"layer": layer, "prop": prop, "time": 0, "value": 90})).unwrap();
assert_eq!(s.execute_checked("keys.delete", json!({})).unwrap(), json!(3));
}
/// Easy Ease, Toggle Hold and Time-Reverse leave a locked layer's keys as they are and keep them
/// selected; unlocked, they change them.
#[test]
fn selection_based_key_edits_skip_a_locked_layer() {
let (mut s, layer, prop) = setup();
let keys = keys_of(&s, layer, prop);
let stored = |s: &Session| s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys.clone();
let before = stored(&s);
s.state.selected_keys = keys.clone();
s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap();
for command in ["keys.easyEase", "keys.toggleHold", "keys.timeReverse"] {
s.execute_checked(command, json!({})).unwrap();
assert_eq!(stored(&s), before, "{command} changed a locked layer's keys");
assert_eq!(s.state.selected_keys, keys, "{command} dropped the selection of a locked layer's keys");
}
s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap();
s.execute_checked("keys.easyEase", json!({})).unwrap();
assert_ne!(stored(&s), before, "unlocked, Easy Ease changes the keys");
}
#[test]
fn spatial_handles_cannot_edit_a_locked_layer() {
let (mut s, layer, _) = setup();
for (time, value) in [(0, [10, 20, 0]), (1, [80, 60, 0])] {
s.execute("prop.addKey", json!({"layer": layer, "path": "transform/position", "time": time, "value": value})).unwrap();
}
let prop = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/position").unwrap().uid;
s.execute("keys.select", json!({"keys": [{"layer": layer, "prop": prop, "time": 0}]})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap();
let before = s.project.to_json();
let selected = s.state.selected_keys.clone();
let undo_len = s.history.undo.len();
let params = json!({"layer": layer, "prop": prop, "time": 0, "out": [15, 25, 0]});
let error = s.execute("keys.setSpatialTangents", params.clone()).unwrap_err().to_string();
assert!(error.contains("locked"), "{error}");
assert_eq!(s.project.to_json(), before);
assert_eq!(s.state.selected_keys, selected);
assert_eq!(s.history.undo.len(), undo_len);
s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap();
let unlocked = s.project.to_json();
s.execute("keys.setSpatialTangents", params).unwrap();
let edited = s.project.to_json();
assert_ne!(edited, unlocked);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.to_json(), unlocked);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.project.to_json(), edited);
}
#[test]
fn pasting_keys_or_motion_paths_cannot_edit_locked_targets() {
for path_clip in [false, true] {
for explicit in [false, true] {
let (mut s, layer, opacity) = setup();
let target_path = if path_clip { "transform/position" } else { "transform/opacity" };
let source = if path_clip {
s.execute("mask.new", json!({"layer": layer, "vertices": [[0, 0], [20, 10], [40, 0]], "closed": false})).unwrap();
s.execute("prop.toggleAnimation", json!({"layer": layer, "path": "masks/#1/path"})).unwrap();
s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("masks/#1/path").unwrap().uid
} else {
opacity
};
s.execute("keys.select", json!({"keys": [{"layer": layer, "prop": source, "time": 0}]})).unwrap();
s.execute("keys.copy", json!({})).unwrap();
s.execute("prop.select", json!({"layer": layer, "path": target_path})).unwrap();
s.execute("time.set", json!({"time": 3})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap();
let params = if explicit { json!({"layer": layer, "path": target_path}) } else { json!({}) };
let before = s.project.to_json();
let selected = s.state.selected_keys.clone();
let undo_len = s.history.undo.len();
let result = s.execute("keys.paste", params.clone());
assert!(result.is_err(), "path={path_clip}, explicit={explicit}: {result:?}");
assert_eq!(s.project.to_json(), before);
assert_eq!(s.state.selected_keys, selected);
assert_eq!(s.history.undo.len(), undo_len);
s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap();
let unlocked = s.project.to_json();
s.execute("keys.paste", params).unwrap();
let pasted = s.project.to_json();
assert_ne!(pasted, unlocked);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.to_json(), unlocked);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.project.to_json(), pasted);
}
}
}
#[test]
fn key_paste_skips_locked_targets_without_changing_layer_pairing() {
let (mut s, locked, locked_prop) = setup();
let other = s.execute("layer.newSolid", json!({"name": "Other", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
for (time, value) in [(0, 100), (1, 80), (2, 60)] {
s.execute("prop.addKey", json!({"layer": other, "path": "transform/opacity", "time": time, "value": value})).unwrap();
}
let other_prop = s.active_comp().unwrap().layer(LayerId(other)).unwrap().props.prop("transform/opacity").unwrap().uid;
let locked_keys = keys_of(&s, locked, locked_prop);
s.state.selected_keys = locked_keys.iter().chain(keys_of(&s, other, other_prop).iter()).copied().collect();
s.execute("keys.copy", json!({})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": true})).unwrap();
s.execute("time.set", json!({"time": 3})).unwrap();
let before = s.project.to_json();
assert_eq!(s.execute("keys.paste", json!({"layers": [locked, other]})).unwrap(), json!(3), "only newly pasted keys are counted");
assert_eq!(keys_of(&s, locked, locked_prop), locked_keys);
assert!(locked_keys.iter().all(|k| s.state.selected_keys.contains(k)));
assert_eq!(s.state.selected_keys.len(), 6);
let keys = &s.active_comp().unwrap().layer(LayerId(other)).unwrap().props.find(other_prop).unwrap().keys;
assert_eq!(keys.iter().filter(|k| k.time.seconds() >= 3.0).map(|k| k.value.as_f64()).collect::<Vec<_>>(), [100.0, 80.0, 60.0]);
let pasted = s.project.to_json();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.to_json(), before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.project.to_json(), pasted);
}
#[test]
fn motion_path_paste_preserves_locked_source_selection() {
for explicit in [false, true] {
let (mut s, source, _) = setup();
s.execute("mask.new", json!({"layer": source, "vertices": [[0, 0], [20, 10], [40, 0]], "closed": false})).unwrap();
s.execute("prop.toggleAnimation", json!({"layer": source, "path": "masks/#1/path"})).unwrap();
let uid = s.active_comp().unwrap().layer(LayerId(source)).unwrap().props.prop("masks/#1/path").unwrap().uid;
s.execute("keys.select", json!({"keys": [{"layer": source, "prop": uid, "time": 0}]})).unwrap();
s.execute("keys.copy", json!({})).unwrap();
let locked_keys = s.state.selected_keys.clone();
let target = s.execute("layer.newSolid", json!({"name": "Target", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.select", json!({"layer": target, "path": "transform/position"})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [source], "switch": "locked", "value": true})).unwrap();
s.state.selected_keys = locked_keys.clone();
let params = if explicit { json!({"layer": target, "path": "transform/position"}) } else { json!({}) };
let before = s.project.to_json();
assert_eq!(s.execute("keys.paste", params).unwrap(), json!(3));
assert!(locked_keys.iter().all(|k| s.state.selected_keys.contains(k)));
assert_eq!(s.state.selected_keys.len(), 4);
assert_eq!(s.state.selected_keys.iter().filter(|k| k.layer == LayerId(target)).count(), 3);
let pasted = s.project.to_json();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.to_json(), before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.project.to_json(), pasted);
}
}
#[test]
fn key_transform_preserves_locked_keys_in_a_mixed_selection() {
let (mut s, locked, locked_prop) = setup();
let unlocked = s.execute("layer.newSolid", json!({"name": "Unlocked", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
for t in 0..3 {
s.execute("prop.addKey", json!({"layer": unlocked, "path": "transform/opacity", "time": t, "value": t * 25})).unwrap();
}
let unlocked_prop = s.active_comp().unwrap().layer(LayerId(unlocked)).unwrap().props.prop("transform/opacity").unwrap().uid;
let locked_keys = keys_of(&s, locked, locked_prop);
s.state.selected_keys = locked_keys.iter().chain(keys_of(&s, unlocked, unlocked_prop).iter()).copied().collect();
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": true})).unwrap();
let stored = |s: &Session, layer, prop| s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys.clone();
let before = stored(&s, locked, locked_prop);
let undo = s.history.undo.len();
for offset in [0.5, 1.0] {
s.execute("keys.transform", json!({"timeOffset": offset, "valueOffset": offset * 10.0, "merge": "mixed-transform", "fromStart": true})).unwrap();
assert_eq!(stored(&s, locked, locked_prop), before);
assert_eq!(s.state.selected_keys.len(), 6);
assert!(locked_keys.iter().all(|k| s.state.selected_keys.contains(k)), "locked selections stay at their original times");
assert!(keys_of(&s, unlocked, unlocked_prop).iter().all(|k| s.state.selected_keys.contains(k)), "unlocked selections follow the keys");
let moved = stored(&s, unlocked, unlocked_prop);
for (i, key) in moved.iter().enumerate() {
assert_eq!(key.time.seconds(), i as f64 + offset);
assert_eq!(key.value.as_f64(), i as f64 * 25.0 + offset * 10.0);
}
}
assert_eq!(s.history.undo.len(), undo + 1, "one merged drag");
let after = stored(&s, unlocked, unlocked_prop);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(stored(&s, locked, locked_prop), before);
assert_eq!(stored(&s, unlocked, unlocked_prop), before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(stored(&s, locked, locked_prop), before);
assert_eq!(stored(&s, unlocked, unlocked_prop), after);
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": false})).unwrap();
s.execute("keys.transform", json!({"timeOffset": 0.5, "valueOffset": 10})).unwrap();
assert_ne!(stored(&s, locked, locked_prop), before, "unlocking makes the keys editable again");
}
+4 -1
View File
@@ -58,7 +58,8 @@ fn slip_moves_the_source_under_fixed_in_and_out() {
let before = get(&s);
assert_eq!((before.start_time, before.in_point.seconds(), before.out_point.seconds()), (Tick::ZERO, 1.0, 3.0));
// Slip the source 0.5 s later: in/out stay, the source (start) moves.
s.execute("layer.slip", json!({"layers": [id], "delta": 0.5})).unwrap();
let r = s.execute("layer.slip", json!({"layers": [id], "delta": 0.5})).unwrap();
assert_eq!(r["slipped"][0]["clamped"], json!(false));
let l = get(&s);
assert_eq!((l.start_time.seconds(), l.in_point, l.out_point), (0.5, before.in_point, before.out_point));
// The source time at comp 2 s is now 1.5 s.
@@ -67,6 +68,8 @@ fn slip_moves_the_source_under_fixed_in_and_out() {
let r = s.execute("layer.slip", json!({"layers": [id], "delta": 5.0})).unwrap();
assert_eq!(get(&s).start_time.seconds(), 1.0);
assert_eq!(r["slipped"][0]["delta"], json!(0.5));
// The response says the 5 s asked for was cut short (#2371).
assert_eq!((r["requested"].clone(), r["slipped"][0]["clamped"].clone()), (json!(5.0), json!(true)));
// Slip by a frame.
s.execute("layer.slipBack", json!({"layers": [id]})).unwrap();
assert!((get(&s).start_time.seconds() - (1.0 - 1.0 / 30.0)).abs() < 1e-9);
+45
View File
@@ -184,6 +184,51 @@ fn mask_interpolation_creates_matching_keys() {
assert!(s.execute_checked("mask.interpolate", json!({"matchingMethod": "magic", "times": [0, 0.8], "layer": clip.0})).is_err());
}
#[test]
fn mask_interpolation_skips_locked_selection_and_rejects_explicit_locked_targets() {
let (mut s, locked, _) = setup(patch_frames(rotating_pose));
let unlocked = effectcraft_project::LayerId(s.execute("layer.newSolid", json!({"width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap());
let a = ShapePath::rect([20.0, 20.0], 10.0, 10.0);
let b = ShapePath::rect([40.0, 30.0], 20.0, 20.0);
let cid = s.active_comp_id().unwrap();
let mut selected = vec![];
let mut masks = vec![];
for layer in [locked, unlocked] {
let uid = new_mask(&mut s, layer, &a);
masks.push(uid);
s.edit("keys", None, |project, _| {
let pr = project.comp_mut(cid).unwrap().layer_mut(layer).unwrap().props.find_group_mut(uid).unwrap().get_mut("path").unwrap();
pr.keys = vec![Keyframe::new(frame_time(0), KV::Path(a.clone())), Keyframe::new(frame_time(20), KV::Path(b.clone()))];
selected.extend(pr.keys.iter().map(|key| crate::KeyRef { layer, prop: pr.uid, time: key.time }));
Ok(())
})
.unwrap();
}
s.execute("layer.setSwitch", json!({"layers": [locked.0], "switch": "locked", "value": true})).unwrap();
s.state.selected_keys = selected.clone();
let before = mask_path(&s, locked, masks[0]).keys;
let options = serde_json::to_value(&s.state.mask_interp).unwrap();
let project = serde_json::to_value(&*s.project).unwrap();
let error = s.execute_checked("mask.interpolate", json!({"layer": locked.0, "mask": masks[0], "times": [0.0, 0.8], "quality": 75})).unwrap_err();
assert!(error.to_string().contains("locked"), "{error}");
assert_eq!(serde_json::to_value(&*s.project).unwrap(), project);
assert_eq!(serde_json::to_value(&s.state.mask_interp).unwrap(), options);
assert_eq!(s.state.selected_keys, selected);
s.execute_checked("mask.interpolate", json!({})).unwrap();
assert_eq!(mask_path(&s, locked, masks[0]).keys, before);
assert_eq!(mask_path(&s, unlocked, masks[1]).keys.len(), 21);
assert_eq!(s.state.selected_keys, selected);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(mask_path(&s, unlocked, masks[1]).keys.len(), 2);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(mask_path(&s, unlocked, masks[1]).keys.len(), 21);
s.state.selected_keys.retain(|key| key.layer == locked);
let selection = s.state.selected_keys.clone();
assert!(s.execute_checked("mask.interpolate", json!({})).is_err());
assert_eq!(mask_path(&s, locked, masks[0]).keys, before);
assert_eq!(s.state.selected_keys, selection);
}
// ---------------------------------------------------------------- Warp Stabilizer
/// Camera offset on frame `f`: a slow pan plus jitter.
+39
View File
@@ -308,6 +308,45 @@ fn puppet_select_all_takes_the_pin_kind_and_several_pins_record_together() {
assert!(s.execute("puppet.recordPin", json!({"layer": id, "pin": a, "pins": [st], "samples": samples})).is_err());
}
/// Editing a parameter of one of several selected pins edits the same parameter of the others,
/// in one undo step; unselected pins keep theirs (#1751).
#[test]
fn editing_one_selected_pin_edits_the_other_selected_pins() {
let (mut s, id) = setup();
let pin = |s: &mut Session, x: i64| s.execute("puppet.addPin", json!({"layer": id, "position": [x, 30]})).unwrap()["pin"].as_u64().unwrap();
let (a, b, c) = (pin(&mut s, 10), pin(&mut s, 50), pin(&mut s, 90));
let position = |s: &Session, pin: u64| layer(s, id).props.find_group(pin).unwrap().get("position").unwrap().clone();
let (pa, pb) = (position(&s, a).uid, position(&s, b).uid);
let at = |s: &Session, pin: u64| position(s, pin).value_at(Tick::ZERO).components();
s.execute("prop.select", json!({"layer": id, "prop": a, "selectKeys": false})).unwrap();
s.execute("prop.select", json!({"layer": id, "prop": b, "selectKeys": false, "add": true})).unwrap();
// A typed value lands on both selected pins, and one Undo takes it back.
let steps = s.history.undo.len();
s.execute("prop.set", json!({"layer": id, "prop": pa, "value": [20, 40], "selected": true})).unwrap();
assert_eq!((at(&s, a), at(&s, b), at(&s, c)), (vec![20.0, 40.0], vec![20.0, 40.0], vec![90.0, 30.0]));
assert_eq!(s.history.undo.len(), steps + 1, "one undo step");
s.undo();
assert_eq!((at(&s, a), at(&s, b)), (vec![10.0, 30.0], vec![50.0, 30.0]));
// A scrub moves each selected pin by the same change from its own value.
s.execute("prop.set", json!({"layer": id, "prop": pb, "value": [55, 25], "selected": true, "offset": true})).unwrap();
assert_eq!((at(&s, a), at(&s, b), at(&s, c)), (vec![15.0, 25.0], vec![55.0, 25.0], vec![90.0, 30.0]));
// The stopwatch follows on both (a new pin starts keyed, so the toggle turns it off).
let animated = |s: &Session| [a, b, c].map(|p| position(s, p).is_animated());
assert_eq!(animated(&s), [true; 3]);
s.execute("prop.toggleAnimation", json!({"layer": id, "prop": pa, "selected": true})).unwrap();
assert_eq!(animated(&s), [false, false, true]);
// Without `selected`, or with only one pin selected, the edit stays on its own pin.
s.execute("prop.set", json!({"layer": id, "prop": pa, "value": [0, 0]})).unwrap();
assert_eq!(at(&s, b), vec![55.0, 25.0]);
s.execute("prop.select", json!({"layer": id, "prop": a, "selectKeys": false})).unwrap();
s.execute("prop.set", json!({"layer": id, "prop": pa, "value": [5, 5], "selected": true})).unwrap();
assert_eq!(at(&s, b), vec![55.0, 25.0]);
}
fn expr_of(s: &Session, layer: u64, uid: u64) -> String {
let l = layer_of(s, layer);
l.props.find(uid).and_then(|p| p.expr.as_ref()).map(|e| e.text.clone()).unwrap_or_default()
+9
View File
@@ -79,6 +79,15 @@ fn prefs_migrate_old_disk_cache_default() {
assert_eq!(Prefs::from_json(r#"{"version": 3, "disk": {"diskCacheMaxGb": 100}}"#).disk.disk_cache_max_gb, 100);
}
#[test]
fn prefs_migrate_old_split_layers_default() {
// Settings saved before #2083 kept "below"; they move to After Effects' "above" once (#2545).
let above = |doc: &str| Prefs::from_json(doc).general.create_split_layers_above;
assert!(above(r#"{"version": 3, "general": {"createSplitLayersAbove": false}}"#));
assert!(above(r#"{"version": 2, "general": {"createSplitLayersAbove": false}}"#));
assert!(!above(r#"{"version": 4, "general": {"createSplitLayersAbove": false}}"#), "a choice made since stays");
}
#[test]
fn prefs_set_validates_keys_types_and_ranges() {
let mut p = Prefs::default();
+182 -3
View File
@@ -7,6 +7,7 @@ use serde_json::json;
use crate::Session;
use crate::commands::text_edit::{MAX_IME_TEXT_BYTES, ime_stamp_revision, ime_surrounding, layer_doc};
use crate::tests::three_d::layer_count;
fn session(text: &str) -> (Session, u64) {
let mut s = Session::default();
@@ -1231,10 +1232,188 @@ fn deselect_all_keeps_an_empty_type_tool_layer() {
s.state.text_edit.as_ref().is_some_and(|e| e.created && e.layer == effectcraft_project::LayerId(new)),
"the Type tool opens the edit on its new, empty layer"
);
let count = |s: &Session| s.active_comp().map_or(0, |c| c.layers.len());
let before = count(&s);
let before = layer_count(&s);
s.execute("edit.deselectAll", json!({})).unwrap();
assert!(s.state.text_edit.is_none());
assert!(s.state.selected_layers.is_empty());
assert_eq!(count(&s), before, "an empty Type-tool layer is kept, as in After Effects");
assert_eq!(layer_count(&s), before, "an empty Type-tool layer is kept, as in After Effects");
}
#[test]
fn duplicate_ends_an_edit_on_already_committed_text() {
// The engine route (MCP, control channel, script) with text that was committed before the
// edit opened and nothing typed inside it: the edit still ends and the copy is selected.
let (mut s, t) = editing("Hello", 5);
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
assert!(s.state.text_edit.is_none(), "the open edit ends on a direct edit.duplicate");
assert_eq!(s.state.selected_layers, vec![effectcraft_project::LayerId(copy)], "and the copy is selected");
assert_eq!(doc(&s, t).text, "Hello", "the committed text is kept on the original");
assert_eq!(doc(&s, copy).text, "Hello", "and carried by the copy");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer_count(&s), 1, "undo removes the copy");
assert!(s.state.text_edit.is_none(), "undo does not reopen the edit");
}
#[test]
fn duplicate_during_an_edit_copies_the_layer_not_a_selected_property_group() {
use effectcraft_project::LayerId;
// Opening the editor by command, as a Type-tool click does, keeps the property selection, so
// an effect, shape item or text animator can still be selected while the user types.
for what in ["effect", "shape item", "text animator"] {
let (mut s, t) = session("Hello");
let (owner, path) = match what {
"effect" => {
s.execute("effect.apply", json!({"layers": [t], "effect": "Gaussian Blur"})).unwrap();
(t, "effects")
}
"shape item" => (s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap(), "contents"),
_ => {
s.execute("layer.addTextAnimator", json!({"layer": t, "properties": ["opacity"]})).unwrap();
(t, "text/animators")
}
};
let items =
|s: &Session| s.active_comp().unwrap().layer(LayerId(owner)).unwrap().props.group(path).unwrap().groups().map(|g| g.uid).collect::<Vec<_>>();
let item = items(&s)[0];
let n = items(&s).len();
let before = layer_count(&s);
s.execute("prop.select", json!({"layer": owner, "prop": item})).unwrap();
begin_edit_at(&mut s, t, 5);
let r = s.execute("edit.duplicate", json!({})).unwrap();
assert!(s.state.text_edit.is_none(), "{what}: Duplicate ends the open edit");
assert_eq!(items(&s).len(), n, "{what}: the selected {what} is not duplicated");
assert_eq!(layer_count(&s), before + 1, "{what}: the edited layer is");
assert_eq!(s.state.selected_layers, vec![LayerId(r[0].as_u64().unwrap())], "{what}: and its copy is selected");
// The selection the edit passed over goes with it: the next Duplicate copies the selected
// copy rather than reaching back to the original's {what}.
s.execute("edit.duplicate", json!({})).unwrap();
assert_eq!(items(&s).len(), n, "{what}: a second Duplicate leaves the original's {what} alone");
assert_eq!(layer_count(&s), before + 2, "{what}: and copies the copy");
// With no edit open, the selected item keeps its precedence over the layers.
s.execute("prop.select", json!({"layer": owner, "prop": item})).unwrap();
s.execute("edit.duplicate", json!({})).unwrap();
assert_eq!(items(&s).len(), n + 1, "{what}: outside an edit Duplicate copies the selected {what}");
assert_eq!(layer_count(&s), before + 2, "{what}: and no layer");
}
}
#[test]
fn duplicate_during_an_edit_leaves_no_key_or_vertex_selected_on_the_original() {
use effectcraft_project::LayerId;
// Opening the editor keeps the keyframe and mask-vertex selection too. Once the copy takes the
// selection, Clear deletes the copy, not a key or a vertex of the original.
for what in ["keyframe", "mask vertex"] {
let (mut s, t) = session("Hello");
let mask = match what {
"keyframe" => {
for time in [0.0, 1.0] {
s.execute("prop.addKey", json!({"layer": t, "path": "transform/opacity", "time": time, "value": 50})).unwrap();
}
s.execute("keys.select", json!({"keys": [{"layer": t, "path": "transform/opacity", "time": 0.0}]})).unwrap();
None
}
// The Pen leaves the new mask's last vertex selected.
_ => Some(s.execute("mask.new", json!({"layer": t, "vertices": [[0, 0], [40, 0], [40, 40]], "closed": true})).unwrap()["mask"].as_u64().unwrap()),
};
let original = |s: &Session| {
let l = s.active_comp().unwrap().layer(LayerId(t)).unwrap();
match mask {
None => l.props.prop("transform/opacity").unwrap().keys.len(),
Some(m) => l.props.find_group(m).unwrap().get("path").unwrap().value.as_path().unwrap().vertices.len(),
}
};
let (n, before) = (original(&s), layer_count(&s));
begin_edit_at(&mut s, t, 5);
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "{what}: the copy is selected");
s.execute("edit.clear", json!({})).unwrap();
assert_eq!(layer_count(&s), before, "{what}: Clear deletes the selected copy");
assert_eq!(original(&s), n, "{what}: and leaves the original's {what} alone");
}
}
#[test]
fn duplicate_during_an_edit_copies_the_edited_layer_after_another_is_selected() {
use effectcraft_project::LayerId;
// An engine route (MCP, control channel, script) can select another layer while the edit
// stays open. The implicit Duplicate still means the edited layer, as Clear does; a named
// target keeps its meaning.
let (mut s, t) = session("Hello");
let other = s.execute("layer.newText", json!({"text": "Other"})).unwrap()["layer"].as_u64().unwrap();
begin_edit_at(&mut s, t, 5);
s.execute("layer.select", json!({"layer": other})).unwrap();
assert!(s.state.text_edit.as_ref().is_some_and(|e| e.layer == LayerId(t)), "selecting another layer leaves the edit open");
let before = layer_count(&s);
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
assert!(s.state.text_edit.is_none(), "Duplicate ends the open edit");
assert_eq!(layer_count(&s), before + 1, "one layer is copied");
assert_eq!(doc(&s, copy).text, "Hello", "the edited layer is copied, not the newly selected one");
assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "and its copy is selected");
// Null parameters name no layer, as everywhere else layers are read.
for unnamed in [json!({"layers": null}), json!({"layer": null})] {
begin_edit_at(&mut s, t, 5);
s.execute("layer.select", json!({"layer": other})).unwrap();
let copy = s.execute("edit.duplicate", unnamed.clone()).unwrap()[0].as_u64().unwrap();
assert_eq!(doc(&s, copy).text, "Hello", "{unnamed}: the edited layer is copied");
}
begin_edit_at(&mut s, t, 5);
let named = s.execute("edit.duplicate", json!({"layer": other})).unwrap()[0].as_u64().unwrap();
assert_eq!(doc(&s, named).text, "Other", "a named layer is copied as named");
}
#[test]
fn duplicate_during_an_edit_with_nothing_selected_copies_the_edited_layer() {
use effectcraft_project::LayerId;
// An engine route can also clear the selection while the edit stays open. Duplicate stays
// available, since its unnamed target is the edited layer, not the selection.
let (mut s, _) = editing("Hello", 5);
s.execute("layer.select", json!({"layers": []})).unwrap();
assert!(s.state.selected_layers.is_empty() && s.state.text_edit.is_some(), "the edit is open with nothing selected");
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
assert!(s.state.text_edit.is_none(), "Duplicate ends the open edit");
assert_eq!(doc(&s, copy).text, "Hello", "and copies the edited layer");
assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "which is selected");
// With no edit open, nothing selected still means nothing to duplicate.
s.execute("layer.select", json!({"layers": []})).unwrap();
let before = layer_count(&s);
assert!(s.execute("edit.duplicate", json!({})).is_err(), "outside an edit Duplicate needs a selection");
assert_eq!(layer_count(&s), before, "and copies nothing");
}
#[test]
fn duplicate_ends_the_open_edit_and_selects_the_copy() {
let (mut s, t) = editing("Hello", 5);
s.execute("text.insert", json!({"text": "!"})).unwrap();
assert_eq!(doc(&s, t).text, "Hello!");
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
// A deselected layer never keeps an open text edit: Duplicate commits the edit through the
// same route as text.endEdit before the selection moves, so every route (menu, shortcut,
// palette, MCP, script) leaves one final state.
assert!(s.state.text_edit.is_none(), "Duplicate ends the open text edit, as Deselect All does");
assert_eq!(s.state.selected_layers, vec![effectcraft_project::LayerId(copy)], "and selects the copy");
assert_eq!(doc(&s, t).text, "Hello!", "the original keeps the typed text");
assert_eq!(doc(&s, copy).text, "Hello!", "the copy carries it too");
// The edit ending is no undoable step of its own (as with text.endEdit): undo takes the
// duplicate, it does not reopen the edit or undo the typing.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer_count(&s), 1, "undo removed the copy");
assert_eq!(doc(&s, t).text, "Hello!", "the typed text stays committed");
assert!(s.state.text_edit.is_none(), "undo does not reopen the edit");
// With no open edit Duplicate is untouched by all of this: same behavior, no extra steps.
s.execute("edit.duplicate", json!({"layers": [t]})).unwrap();
assert_eq!(layer_count(&s), 2, "a plain duplicate still duplicates");
assert!(s.state.text_edit.is_none());
}
#[test]
fn duplicate_of_an_empty_type_tool_layer_ends_the_edit_and_copies_it() {
use effectcraft_project::LayerId;
let (mut s, _keep) = session("Hello");
let new = s.execute("layer.newText", json!({"text": "", "position": [80.0, 80.0], "edit": true})).unwrap()["layer"].as_u64().unwrap();
assert!(s.state.text_edit.as_ref().is_some_and(|e| e.created && e.layer == LayerId(new)), "the Type tool opens the edit on its new, empty layer");
let before = layer_count(&s);
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
assert!(s.state.text_edit.is_none(), "the edit ends the way text.endEdit ends it");
assert_eq!(layer_count(&s), before + 1, "the empty layer is kept, as text.endEdit keeps it, and copied");
assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "and the copy is selected");
}
+2 -1
View File
@@ -26,7 +26,8 @@ fn auto_orient_command() {
assert!(!effectcraft_render::three_d::camera::is_two_node(&s.active_comp().unwrap().layers[0]));
}
fn layer_count(s: &Session) -> usize {
/// Layers in the active comp.
pub(crate) fn layer_count(s: &Session) -> usize {
s.active_comp().unwrap().layers.len()
}
+73
View File
@@ -298,6 +298,25 @@ fn interpolation_dialog_temporal_spatial_and_roving() {
assert_ne!(prop(&s, l, "transform/position").keys[0].out_interp, Interp::Hold);
}
/// Editing another key's value or time keeps the roving key at its share of the path (#1284: the
/// roving key went back to where it was after edits to other keys).
#[test]
fn roving_key_follows_value_and_time_edits_to_other_keys() {
let (mut s, l) = setup();
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0])), (0.3, json!([300, 0])), (2.0, json!([400, 0]))]);
let uid = prop(&s, l, "transform/position").uid;
s.execute("keys.select", json!({"keys": [{"layer": l, "prop": uid, "time": 0.3}]})).unwrap();
s.execute("keys.interpolation", json!({"roving": true})).unwrap();
let roving_at = |s: &Session| prop(s, l, "transform/position").keys[1].time.seconds();
assert!((roving_at(&s) - 1.5).abs() < 0.01, "start: {}", roving_at(&s));
// The last key's value moves to 800 px: the path is 300 + 500 px, so the roving key is at 300 / 800.
s.execute("keys.set", json!({"layer": l, "path": "transform/position", "time": 2.0, "value": [800, 0]})).unwrap();
assert!((roving_at(&s) - 0.75).abs() < 0.02, "value edit: {}", roving_at(&s));
// The last key moves to 3 s: the same 300 / 800 share of 3 s.
s.execute("keys.set", json!({"layer": l, "path": "transform/position", "time": 2.0, "newTime": 3.0})).unwrap();
assert!((roving_at(&s) - 1.125).abs() < 0.02, "time edit: {}", roving_at(&s));
}
#[test]
fn graph_editor_key_and_handle_edits() {
let (mut s, l) = setup();
@@ -325,6 +344,34 @@ fn graph_editor_key_and_handle_edits() {
assert_eq!(prop(&s, l, "transform/opacity").keys[0].out_interp, Interp::Linear);
}
#[test]
fn graph_editor_ease_edits_reject_a_locked_layer() {
let (mut s, l) = setup();
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (2.0, json!(0))]);
select_prop_keys(&mut s, l, "transform/opacity");
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "locked", "value": true})).unwrap();
let before = prop(&s, l, "transform/opacity");
let selected = s.state.selected_keys.clone();
let undo = s.history.undo.len();
for side in ["in", "out"] {
let error =
s.execute("keys.setEase", json!({"layer": l, "path": "transform/opacity", "time": 1.0, "side": side, "speed": 200, "influence": 60})).unwrap_err();
assert!(error.to_string().contains("locked"), "{error}");
assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys);
assert_eq!(s.state.selected_keys, selected);
assert_eq!(s.history.undo.len(), undo);
}
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "locked", "value": false})).unwrap();
s.execute("keys.setEase", json!({"layer": l, "path": "transform/opacity", "time": 1.0, "side": "out", "speed": 200, "influence": 60})).unwrap();
let after = prop(&s, l, "transform/opacity");
assert_eq!(after.keys[1].out_interp, Interp::Bezier);
assert_eq!(after.keys[1].out_ease[0].speed, 200.0);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(prop(&s, l, "transform/opacity").keys, after.keys);
}
#[test]
fn convert_expression_to_keyframes_on_a_reversed_layer_keeps_values() {
let (mut s, l) = setup();
@@ -1483,3 +1530,29 @@ fn a_typed_component_applies_only_that_component_to_the_selected_layers() {
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [1.0, 2.0, 3.0], "selected": true})).unwrap();
assert_eq!(pos(&s, c), vec![1.0, 2.0, 3.0]);
}
/// #2485: linked Audio Levels typed with several layers selected: each takes the typed Left, and
/// its Right moves by its own change, so every layer keeps its own stereo balance.
#[test]
fn a_typed_component_moves_its_follow_components_by_each_layers_own_change() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Sound", "width": 64, "height": 36, "duration": 4})).unwrap();
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap();
let a = s.execute("layer.addItem", json!({"item": "Sound"})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.addItem", json!({"item": "Sound"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": a, "path": "audio/levels", "value": [-6.0, -12.0]})).unwrap();
s.execute("prop.set", json!({"layer": b, "path": "audio/levels", "value": [-20.0, -30.0]})).unwrap();
s.execute("layer.select", json!({"layers": [a, b]})).unwrap();
let uid = prop(&s, a, "audio/levels").uid;
let undo = s.history.undo.len();
s.execute_checked("prop.set", json!({"layer": a, "prop": uid, "value": [-3.0, -9.0], "selected": true, "dims": [0], "follow": [1]})).unwrap();
assert_eq!(prop(&s, a, "audio/levels").value.components(), vec![-3.0, -9.0]);
assert_eq!(prop(&s, b, "audio/levels").value.components(), vec![-3.0, -13.0], "B rises 17 dB on both channels");
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
// Keyed at a time other than the CTI: B's change is measured on its key there.
animate(&mut s, b, "audio/levels", &[(0.0, json!([-20.0, -30.0])), (1.0, json!([-10.0, -40.0]))]);
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [-4.0, -10.0], "time": 1.0, "selected": true, "dims": [0], "follow": [1]})).unwrap();
let keys: Vec<Vec<f64>> = prop(&s, b, "audio/levels").keys.iter().map(|k| k.value.components()).collect();
assert_eq!(keys, vec![vec![-20.0, -30.0], vec![-4.0, -34.0]], "B's 1 s key rises 6 dB on both channels");
}
@@ -52,6 +52,14 @@ fn export_and_reimport_timeline_commands() {
let r = s.execute_checked("file.exportTimeline", json!({"comp": main, "path": tmp("NoExt"), "format": "otio", "prerender": "none"})).unwrap();
assert!(r["path"].as_str().unwrap().ends_with("NoExt.otio"));
assert!(s.execute_checked("file.exportTimeline", json!({"path": tmp("x.xml"), "prerender": "sometimes"})).is_err());
// In the background (the export dialog, #2269): a job, then the same file and result.
let path = tmp("Background.xml");
let r = s.execute_checked("file.exportTimeline", json!({"comp": main, "path": path, "background": true})).unwrap();
assert!(r["job"].is_string(), "{r}");
s.wait_jobs();
let done = s.job_log.last().unwrap();
assert_eq!((done.status.as_str(), done.result["path"].as_str()), ("done", Some(path.as_str())));
assert!(std::fs::read_to_string(&path).unwrap().contains("<xmeml"));
assert!(s.execute_checked("file.importTimeline", json!({"path": "/nope/a.prproj"})).is_err());
let formats = s.execute("file.timelineFormats", json!({})).unwrap();
assert_eq!(formats.as_array().unwrap().len(), 6);
+6
View File
@@ -510,6 +510,12 @@ fn layer_styles_collapsed_precomps_and_cpu_fallbacks() {
adj.switches.adjustment = true;
s.effect(&mut adj, "ec.channel.invert", &[]);
s.push(adj);
// An adjustment layer in another blend mode blends the adjusted comp in that mode (#2618).
let mut adj = s.solid([1.0, 1.0, 1.0], 70, 40);
adj.switches.adjustment = true;
adj.blend_mode = BlendMode::Screen;
set(&mut adj, "transform/opacity", Value::Scalar(60.0));
s.push(adj);
check(&format!("collapse + adjustment {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0);
}
}
+2 -4
View File
@@ -182,10 +182,8 @@ fn violations(src: &str) -> Vec<(usize, String)> {
let tok = next_token(&s, &mut i);
match tok {
Tok::Open => levels.push(vec![]),
Tok::Close => {
if levels.len() > 1 {
levels.pop();
}
Tok::Close if levels.len() > 1 => {
levels.pop();
}
Tok::Sep => {
if let Some(l) = levels.last_mut() {
+9 -3
View File
@@ -352,8 +352,8 @@ fn draw_precomp(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas:
/// Adjustment layer (`Renderer::draw_adjustment`): the effect stack runs on the comp below,
/// resident on the GPU (non-GPU effects read back, run on the CPU and upload), and the result
/// replaces the canvas inside the layer's footprint × track matte × opacity. `None` = draw it
/// on the CPU.
/// replaces the canvas inside the layer's footprint × track matte × opacity, or blends onto it in
/// the layer's blend mode when that isn't Normal. `None` = draw it on the CPU.
fn draw_adjustment(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut GpuImage) -> Option<()> {
let opacity = r.layer_opacity(ctx, layer);
let Some(foot) = r.adjustment_footprint(ctx, layer) else { return Some(()) };
@@ -379,7 +379,13 @@ fn draw_adjustment(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canv
if let Some(c) = pipe.to_blend() {
adjusted = ops::convert(e, &adjusted, &c);
}
*canvas = ops::adjust_mix(e, canvas, &adjusted, &matte, opacity);
*canvas = if layer.blend_mode == BlendMode::Normal {
ops::adjust_mix(e, canvas, &adjusted, &matte, opacity)
} else {
// The adjusted comp is a layer in that mode over the comp below (#2618).
let iso = ops::matte(e, &adjusted, &matte, effectcraft_project::MatteKind::Alpha);
ops::blend_full(e, canvas, &iso, layer.blend_mode, opacity, layer.id.0 as u32, None)
};
Some(())
}
+13 -1
View File
@@ -522,7 +522,7 @@ impl<'a> Renderer<'a> {
}
/// Adjustment layer: effects applied to the comp below, limited to the layer's footprint
/// and its track matte.
/// and its track matte, and blended back in the layer's blend mode.
fn draw_adjustment(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image, opacity: f32) {
let Some(mut foot) = self.source(ctx, layer) else { return };
let _ = masks::apply(ctx, layer, &mut foot, self.mask_blur(ctx, layer));
@@ -548,6 +548,18 @@ impl<'a> Renderer<'a> {
if adjusted.img.width != canvas.width || adjusted.img.height != canvas.height {
return;
}
if layer.blend_mode != BlendMode::Normal {
// The adjusted comp is a layer in that mode over the comp below, as in After Effects:
// an adjustment layer with no effects in Screen screens the comp with itself (#2618).
let mut iso = adjusted.img;
iso.data.par_iter_mut().zip(matte.data.par_iter()).for_each(|(p, m)| {
for c in p.iter_mut() {
*c *= m[3];
}
});
canvas.blend_from(&iso, layer.blend_mode, opacity, layer.id.0 as u32);
return;
}
canvas.data.par_iter_mut().zip(adjusted.img.data.par_iter()).zip(matte.data.par_iter()).for_each(|((c, a), m)| {
let k = m[3] * opacity;
for i in 0..4 {
+26
View File
@@ -206,6 +206,32 @@ fn track_matte_limits_an_adjustment_layer() {
}
}
/// An adjustment layer's blend mode blends the adjusted comp onto the comp below, inside its
/// footprint: with no effects, Screen screens a 0.25 grey with itself, Multiply darkens it,
/// Normal leaves it, and Opacity mixes toward the result (#2618).
#[test]
fn adjustment_layer_blends_in_its_mode() {
for (mode, opacity, want) in
[(BlendMode::Normal, 100.0, 0.25), (BlendMode::Screen, 100.0, 0.4375), (BlendMode::Multiply, 100.0, 0.0625), (BlendMode::Screen, 50.0, 0.34375)]
{
let (mut p, cid, comp) = setup();
let mut adj = solid(&mut p, &comp, [1.0, 1.0, 1.0], 100, 100);
adj.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 50.0, 0.0]);
adj.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(opacity);
adj.switches.adjustment = true;
adj.blend_mode = mode;
let bg = solid(&mut p, &comp, [0.25, 0.25, 0.25], 200, 100);
let c = p.comp_mut(cid).unwrap();
c.layers.push(adj);
c.layers.push(bg);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
let (inside, outside) = (img.get(50, 50), img.get(150, 50));
assert!((inside[0] - want).abs() < 1e-4, "{mode:?} {opacity}: {inside:?}");
assert!((outside[0] - 0.25).abs() < 1e-4, "{mode:?} {opacity}: outside the footprint {outside:?}");
assert_eq!(inside[3], 1.0, "{mode:?} keeps the comp opaque");
}
}
#[test]
fn shape_layer_draws_fill_and_stroke() {
let (mut p, cid, comp) = setup();
+16 -8
View File
@@ -446,18 +446,26 @@ fn an_empty_glyph_does_not_cover_its_character() {
assert_eq!(fallback_for('\u{e001}', inter), id, "fallback finds the drawn one");
}
/// A Microsoft family that isn't installed renders in its installed metric-compatible
/// substitute instead of Inter, and still reports itself missing (#1677).
/// A missing Microsoft family uses its metric-compatible substitute instead of Inter and still
/// reports itself missing (#1677). An installed original family takes precedence.
#[test]
fn microsoft_families_resolve_to_metric_compatible_substitutes() {
add_font_data(test_font("Carlito"));
add_font_data(test_font("Caladea"));
let calibri = resolve("Calibri", "Regular");
assert_eq!(face(calibri.face).info.family, "Carlito");
assert!(calibri.missing, "the original name stays missing for Find Missing Fonts");
let cambria = resolve("Cambria", "Bold");
assert_eq!(face(cambria.face).info.family, "Caladea");
assert!(cambria.missing);
for (requested, style, substitute) in [("Calibri", "Regular", "Carlito"), ("Cambria", "Bold", "Caladea")] {
let alias = resolve_alias(requested, style).expect("the substitute is available");
assert_eq!(face(alias.face).info.family, substitute);
assert!(alias.missing, "the original name stays missing for Find Missing Fonts");
let resolved = resolve(requested, style);
let family = &face(resolved.face).info.family;
if family.eq_ignore_ascii_case(requested) {
assert!(!resolved.missing, "an installed original family is available");
} else {
assert_eq!(family, substitute);
assert!(resolved.missing);
}
}
let unknown = resolve("No Such Family 1677", "Regular");
assert_eq!(face(unknown.face).info.family, DEFAULT_FAMILY);
assert!(unknown.missing);
+205
View File
@@ -0,0 +1,205 @@
//! Accessibility helpers (docs/gaps.md gap 25): names, roles and states for the widgets the app
//! paints itself, and the small pieces of list keyboard handling the panels share.
//!
//! egui builds an AccessKit node for every widget that can take the keyboard focus, but a widget
//! made with `ui.interact` has no name and no role until the panel says what it is. These helpers
//! write that onto the widget's node *after* the widget is made each frame (egui rebuilds the
//! tree every pass), and keep whatever egui already put there (its role for a `DragValue`, the
//! numeric value of a `Slider`).
//!
//! Names are the same translated strings the panel draws (`i18n::tr`), never hard-coded English.
//! `tests/ui_accessibility_audit.rs` walks the tree and fails on an unnamed control in the panels
//! that have had their pass.
use egui::accesskit::{HasPopup, Role, Toggled};
use egui::{Context, Id, Response};
/// Set `f` on `r`'s AccessKit node (nothing when AccessKit is off). egui only fills a node's
/// bounds and actions for widgets that can take the keyboard focus; a widget that cannot (the
/// non-current rows of a list) still needs both, or assistive technology cannot place or click it.
fn node(r: &Response, f: impl FnOnce(&mut egui::accesskit::Node)) {
r.ctx.accesskit_node_builder(r.id, |n| {
n.set_bounds(egui::accesskit::Rect { x0: r.rect.min.x.into(), y0: r.rect.min.y.into(), x1: r.rect.max.x.into(), y1: r.rect.max.y.into() });
if r.sense.is_focusable() {
n.add_action(egui::accesskit::Action::Focus);
}
if r.sense.senses_click() {
n.add_action(egui::accesskit::Action::Click);
}
f(n);
});
}
/// Give `r`'s node a role and leave its name to the caller.
pub(crate) fn role(r: &Response, role: Role) {
node(r, |n| n.set_role(role));
}
/// Give `r`'s node a role and a name.
pub(crate) fn role_name(r: &Response, role: Role, label: &str) {
node(r, |n| {
n.set_role(role);
n.set_label(label);
});
}
/// Name `r`'s node (`label`), keeping the role egui gave it (spin button, slider, text input).
pub(crate) fn name(r: &Response, label: &str) {
node(r, |n| n.set_label(label));
}
/// Give `r`'s node a current value and keep the name it already has.
pub(crate) fn value(r: &Response, value: &str) {
node(r, |n| n.set_value(value));
}
/// Name `r`'s node and give it a current value (a spin button's or a text field's text).
pub(crate) fn name_value(r: &Response, label: &str, value: &str) {
node(r, |n| {
n.set_label(label);
n.set_value(value);
});
}
/// A push button; `enabled` false marks it disabled for assistive technology (it stays visible).
pub(crate) fn button(r: &Response, label: &str, enabled: bool) {
node(r, |n| {
n.set_role(Role::Button);
n.set_label(label);
if !enabled {
n.set_disabled();
}
});
}
/// A button that opens a menu or popup: `open` is whether it is open now.
pub(crate) fn menu_button(r: &Response, label: &str, open: bool) {
node(r, |n| {
n.set_role(Role::Button);
n.set_label(label);
n.set_has_popup(HasPopup::Menu);
n.set_expanded(open);
});
}
/// A button that shows or hides something (a twirl): `expanded` is the state it leads to from.
pub(crate) fn disclosure(r: &Response, label: &str, expanded: bool) {
node(r, |n| {
n.set_role(Role::Button);
n.set_label(label);
n.set_expanded(expanded);
});
}
/// A check box.
pub(crate) fn check_box(r: &Response, label: &str, checked: bool, enabled: bool) {
node(r, |n| {
n.set_role(Role::CheckBox);
n.set_label(label);
n.set_toggled(if checked { Toggled::True } else { Toggled::False });
if !enabled {
n.set_disabled();
}
});
}
/// A drop-down: `label` says what it sets and `value` is what it is set to.
pub(crate) fn combo_box(r: &Response, label: &str, value: &str, enabled: bool) {
node(r, |n| {
n.set_role(Role::ComboBox);
n.set_label(label);
n.set_value(value);
n.set_has_popup(HasPopup::Menu);
n.set_expanded(false);
if !enabled {
n.set_disabled();
}
});
}
/// A row of a tree: selected, its depth (`level`, 1 at the top), whether it is open (`None` for a
/// leaf) and its place among its siblings.
pub(crate) fn tree_item(r: &Response, label: &str, selected: bool, level: usize, expanded: Option<bool>) {
node(r, |n| {
n.set_role(Role::TreeItem);
n.set_label(label);
n.set_selected(selected);
n.set_level(level);
if let Some(open) = expanded {
n.set_expanded(open);
}
});
}
/// An entry of a flat list: `position` is its 1-based place among `size`.
pub(crate) fn list_item(r: &Response, label: &str, selected: bool, position: usize, size: usize) {
node(r, |n| {
n.set_role(Role::ListItem);
n.set_label(label);
n.set_selected(selected);
n.set_position_in_set(position);
n.set_size_of_set(size);
});
}
/// An entry of a menu or popup list; `checked` is `Some` for an entry that is on or off.
pub(crate) fn menu_item(r: &Response, label: &str, checked: Option<bool>) {
node(r, |n| {
n.set_label(label);
match checked {
Some(on) => {
n.set_role(Role::MenuItemCheckBox);
n.set_toggled(if on { Toggled::True } else { Toggled::False });
}
None => n.set_role(Role::MenuItem),
}
});
}
/// A text field with a name (and its text as the value).
pub(crate) fn text_input(r: &Response, label: &str, text: &str) {
node(r, |n| {
n.set_role(Role::TextInput);
n.set_label(label);
n.set_value(text);
});
}
/// A progress bar: `fraction` 0..=1 and the sentence it shows.
pub(crate) fn progress(r: &Response, label: &str, fraction: f32) {
node(r, |n| {
n.set_role(Role::ProgressIndicator);
n.set_label(label);
n.set_min_numeric_value(0.0);
n.set_max_numeric_value(1.0);
n.set_numeric_value(f64::from(fraction.clamp(0.0, 1.0)));
});
}
// ------------------------------------------------------------------------------------------
// List keyboard handling
/// Ask for the keyboard focus on widget `id` once it is drawn (a list moves its focus to the row
/// the arrow keys reached, which may not be focusable, or drawn, until the next pass). `key`
/// names the list.
pub(crate) fn focus_next_pass(ctx: &Context, key: &str, id: Id) {
let pass = ctx.cumulative_pass_nr();
ctx.data_mut(|d| d.insert_temp(Id::unique(("a11y-pending-focus", key)), (id, pass)));
}
/// True once, for the widget `focus_next_pass` named for list `key`: the caller then requests the
/// focus. A request nobody answers within a few passes is dropped.
pub(crate) fn take_pending_focus(ctx: &Context, key: &str, id: Id) -> bool {
let slot = Id::unique(("a11y-pending-focus", key));
let now = ctx.cumulative_pass_nr();
let Some((want, at)) = ctx.data(|d| d.get_temp::<(Id, u64)>(slot)) else { return false };
if now > at.saturating_add(4) {
ctx.data_mut(|d| d.remove::<(Id, u64)>(slot));
return false;
}
if want != id || now == at {
return false;
}
ctx.data_mut(|d| d.remove::<(Id, u64)>(slot));
true
}
+4 -47
View File
@@ -90,7 +90,7 @@ impl EffectcraftApp {
/// Apply the engine events drained this tick: open a comp, show a toast, open a URL, run a frontend command,
/// purge frame caches or copy an image; command and clipboard failures go to `app.ui.status`.
pub(super) fn handle_events(&mut self, ctx: &egui::Context) {
pub(crate) fn handle_events(&mut self, ctx: &egui::Context) {
for ev in self.session.drain_events() {
match ev {
effectcraft_engine::Event::OpenComp(c) => {
@@ -98,7 +98,7 @@ impl EffectcraftApp {
self.ui.timeline.pps = None;
}
effectcraft_engine::Event::Toast { message, .. } => self.toast = Some((message, self.now)),
effectcraft_engine::Event::OpenUrl(url) => open_url(ctx, url),
effectcraft_engine::Event::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
// Frames are keyed by content: an edit keeps those of comps it doesn't touch.
effectcraft_engine::Event::ProjectChanged { .. } => {}
effectcraft_engine::Event::Frontend { command, params } => {
@@ -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);
@@ -581,48 +583,3 @@ impl EffectcraftApp {
}
}
}
/// Opens `url`: on Windows a `file://` folder (Reveal in Explorer) in File Explorer, since the
/// system browser would get it otherwise (#1961); anything else in the browser.
fn open_url(ctx: &egui::Context, url: String) {
#[cfg(windows)]
if let Some(path) = file_url_path(&url) {
// The engine only sends folders it checked are plain folders (`reveal_url`).
if std::process::Command::new("explorer").arg(&path).spawn().is_ok() {
return;
}
}
ctx.open_url(egui::OpenUrl::new_tab(url));
}
/// The Windows path of a `file:///C:/…` (or `file:////server/share/…`) URL, percent-decoded.
#[cfg(any(windows, test))]
fn file_url_path(url: &str) -> Option<String> {
let rest = url.strip_prefix("file:///")?;
let mut bytes = Vec::with_capacity(rest.len());
let mut it = rest.bytes();
while let Some(b) = it.next() {
if b == b'%' {
let hex = [it.next()?, it.next()?];
bytes.push(u8::from_str_radix(std::str::from_utf8(&hex).ok()?, 16).ok()?);
} else {
bytes.push(b);
}
}
let path = String::from_utf8(bytes).ok()?.replace('/', "\\");
// `file:////server/share` is the UNC path `\\server\share`.
Some(if path.starts_with('\\') { format!("\\{path}") } else { path })
}
#[cfg(test)]
mod tests {
/// #1961: Reveal's `file://` URLs map back to the folder Explorer opens.
#[test]
fn file_urls_map_to_windows_paths() {
assert_eq!(super::file_url_path("file:///C:/Footage/My%20Shots/").as_deref(), Some("C:\\Footage\\My Shots\\"));
assert_eq!(super::file_url_path("file:///C:/Clips/%E6%98%A0%E5%83%8F").as_deref(), Some("C:\\Clips\\映像"));
assert_eq!(super::file_url_path("file:////server/share/x").as_deref(), Some("\\\\server\\share\\x"));
assert_eq!(super::file_url_path("https://getartcraft.com"), None);
assert_eq!(super::file_url_path("file:///C:/bad%2"), None);
}
}
+1 -1
View File
@@ -406,7 +406,7 @@ impl EffectcraftApp {
/// A press in a panel (its body, tab or group header) focuses it; another Composition viewer
/// also becomes the active one.
fn focus_panel(&mut self, ctx: &egui::Context, p: PanelKind) {
pub(crate) fn focus_panel(&mut self, ctx: &egui::Context, p: PanelKind) {
self.ui.focused = p;
panels::viewer_text::panel_clicked(self, ctx, p);
if let Some(id) = panels::viewers::id_of(p) {
+4 -2
View File
@@ -226,7 +226,9 @@ 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() {
let snap_resp = widgets::checkbox(ui, snap, app.ui.snapping, &t, egui::Id::unique("snapping"));
crate::a11y::check_box(&snap_resp, tr("Snapping"), app.ui.snapping, true);
if snap_resp.changed() {
let _ = app.session.execute("view.snapping", serde_json::json!({}));
app.ui.snapping = app.session.state.snapping;
}
@@ -745,7 +747,7 @@ fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
p.text(pos2(x, cy), Align2::LEFT_CENTER, tr("Mesh:"), Tokens::ui(12.0), t.text_dim);
x += 40.0;
let show = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0));
if widgets::checkbox(ui, show, o.show_mesh, &t, egui::Id::unique("puppet-show-mesh")).clicked() {
if widgets::checkbox(ui, show, o.show_mesh, &t, egui::Id::unique("puppet-show-mesh")).changed() {
let _ = app.session.execute("puppet.mesh", json!({"showMesh": !o.show_mesh}));
}
app.auto.add("header.puppet.showMesh", show, "Show mesh");
+4 -4
View File
@@ -1907,7 +1907,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[
("layer.newShape", "Shape Layer", "シェイプレイヤー"),
("layer.newAdjustment", "Adjustment Layer", "調整レイヤー"),
("layer.newContentAwareFill", "Content-Aware Fill Layer...", "コンテンツに応じた塗りつぶしレイヤー..."),
("layer.newPrimitive", "3D Primitive...", "3D プリミティブ..."),
("layer.newPrimitive", "3D Primitive...", "3Dプリミティブ..."),
("layer.new3dPrimitive", "Cube", "立方体"),
("layer.new3dPrimitive", "Sphere", "球体"),
("layer.new3dPrimitive", "Plane", "平面"),
@@ -2206,9 +2206,9 @@ const JAPANESE: &[(&str, &str, &str)] = &[
("view.zoomOut", "Zoom Out", "ズームアウト"),
("", "Resolution", "解像度"),
("view.res.full", "Full", "フル画質"),
("view.res.half", "Half", "1/2 画質"),
("view.res.third", "Third", "1/3 画質"),
("view.res.quarter", "Quarter", "1/4 画質"),
("view.res.half", "Half", "1/2画質"),
("view.res.third", "Third", "1/3画質"),
("view.res.quarter", "Quarter", "1/4画質"),
("view.res.custom", "Custom...", "カスタム..."),
("view.displayColorManagement", "Use Display Color Management", "ディスプレイカラーマネジメントを使用"),
("", "Simulate Output", "出力をシミュレート"),
+104 -42
View File
@@ -316,7 +316,6 @@ const JAPANESE: &[(&str, &str)] = &[
("Duration:", "デュレーション:"),
("Edit Gradient…", "グラデーションを編集…"),
("Edit Target: {}", "編集対象:{}"),
("Edit in the Timeline", "タイムラインで編集"),
("EffectCraft didn't quit normally last time.", "EffectCraftは前回正常に終了しませんでした。"),
("Effects", "エフェクト"),
("Effects & Presets options", "エフェクト&プリセットのオプション"),
@@ -396,7 +395,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 +463,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 +657,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 +680,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 +693,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 +793,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 +863,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 +878,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", "ドラフトのリアルタイムシャドウ"),
@@ -913,9 +912,45 @@ const JAPANESE: &[(&str, &str)] = &[
"トラッカーパネルのフェイストラッキング(輪郭のみと詳細機能)はここで選んだモデルを使用します。モデルはオープンソースで、要求したときだけダウンロードされ、公開されているSHA-256と照合されます。",
),
("Match System", "システムに合わせる"),
("Default", "デフォルト"),
("Stored, but not used by EffectCraft yet", "保存されますが、EffectCraftではまだ使用されません"),
("Total Offset", "合計オフセット"),
("Per Layer", "レイヤーごと"),
("Track", "トラック"),
("Selection Tool", "選択ツール"),
("Hand Tool", "手のひらツール"),
("Zoom Tool", "ズームツール"),
("Orbit Around Cursor Tool", "カーソルのまわりを周回ツール"),
("Pan Under Cursor Tool", "カーソルの下でパンツール"),
("Dolly Towards Cursor Tool", "カーソルに向かってドリーツール"),
("Rotation Tool", "回転ツール"),
("Pan Behind (Anchor Point) Tool", "アンカーポイントツール"),
("Rectangle Tool", "長方形ツール"),
("Rounded Rectangle Tool", "角丸長方形ツール"),
("Ellipse Tool", "楕円形ツール"),
("Polygon Tool", "多角形ツール"),
("Star Tool", "スターツール"),
("Pen Tool", "ペンツール"),
("Add Vertex Tool", "頂点を追加ツール"),
("Delete Vertex Tool", "頂点を削除ツール"),
("Convert Vertex Tool", "頂点を切り替えツール"),
("Mask Feather Tool", "マスクの境界のぼかしツール"),
("Horizontal Type Tool", "横書き文字ツール"),
("Vertical Type Tool", "縦書き文字ツール"),
("Brush Tool", "ブラシツール"),
("Clone Stamp Tool", "コピースタンプツール"),
("Eraser Tool", "消しゴムツール"),
("Roto Brush Tool", "ロトブラシツール"),
("Refine Edge Tool", "エッジを調整ツール"),
("Object Matte Tool", "オブジェクトマットツール"),
("Puppet Position Pin Tool", "パペット位置ピンツール"),
("Puppet Starch Pin Tool", "パペットスターチピンツール"),
("Puppet Bend Pin Tool", "パペットベンドピンツール"),
("Puppet Advanced Pin Tool", "パペット詳細ピンツール"),
("Puppet Overlap Pin Tool", "パペット重なりピンツール"),
("Shortcut", "ショートカット"),
("Range", "範囲"),
("Play From", "再生開始位置"),
("Force alphabetical order", "アルファベット順を強制"),
("Frame rate (fps)", "フレームレート (fps)"),
("Import Kind", "読み込みの種類"),
@@ -925,11 +960,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 +979,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 +988,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 +1000,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,14 +1093,22 @@ 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", "フレームレート"),
("Resolution", "解像度"),
("Overlays", "オーバーレイ"),
("Layer Controls", "レイヤーコントロール"),
("Loop", "ループ"),
("Cache Before Playback", "再生前にキャッシュ"),
("Pre-roll", "プリロール"),
("Post-roll", "ポストロール"),
("If caching, play cached frames", "キャッシュ中はキャッシュ済みフレームを再生"),
("File Path", "ファイルパス"),
("Interpret Footage", "フッテージの解釈"),
("Create a new Folder", "新しいフォルダーを作成"),
@@ -1075,7 +1118,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 +1128,12 @@ 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レンダラー"),
("Black", "黒"),
("White", "白"),
("Composition Settings", "コンポジション設定"),
("Preset Name", "プリセット名"),
("Save Preset…", "プリセットを保存…"),
("Delete Preset", "プリセットを削除"),
@@ -1099,9 +1145,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,12 +1223,23 @@ 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", "適用するレイヤーを選択してください"),
("Search effects and presets", "エフェクトとプリセットを検索"),
("Building waveform…", "波形を生成中…"),
("No effects. Drag one from Effects & Presets.", "エフェクトがありません。エフェクト&プリセットからドラッグしてください。"),
(
"Click the ring to add a colour; Alt-click a stop (or drag it off) to remove it",
"リングをクリックするとカラーを追加します。Altを押しながらストップをクリック(または外へドラッグ)すると削除します",
),
(
"Start from a ready-made project. Opening a template makes an untitled copy.",
"既成のプロジェクトから開始します。テンプレートを開くと無題のコピーが作成されます。",
),
("Square Pixels", "スクエアピクセル"),
("Alpha Expansion:", "アルファの拡張:"),
("Fill Method:", "塗りの方法:"),
("Edge Blend", "エッジのブレンド"),
@@ -1207,7 +1264,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Adjust Exposure", "露出を調整"),
("Snapshot taken", "スナップショットを撮影しました"),
("Fast Previews", "高速プレビュー"),
("3D View", "3D ビュー"),
("3D View", "3Dビュー"),
("Current time", "現在の時間"),
("Layer Transform", "レイヤーのトランスフォーム"),
("Reset Transform", "トランスフォームをリセット"),
@@ -1220,6 +1277,19 @@ const JAPANESE: &[(&str, &str)] = &[
("Blend Mode", "描画モード"),
("Show mesh", "メッシュを表示"),
("Record Options...", "記録オプション..."),
("Apply", "適用"),
("Analyze", "分析"),
("Add Point", "ポイントを追加"),
("Remove Point", "ポイントを削除"),
("Render", "レンダリング"),
("Save Current as Template…", "テンプレートとして保存…"),
("New Composition From Footage", "フッテージから新規コンポジション"),
("Search project", "プロジェクトを検索"),
("Search", "検索"),
("(no composition)", "(コンポジションなし)"),
("per second", "毎秒"),
("Waveform", "波形"),
("steps", "ステップ"),
];
const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
@@ -1360,7 +1430,6 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
("Duration:", "持续时间:"),
("Edit Gradient…", "编辑渐变…"),
("Edit Target: {}", "编辑目标:{}"),
("Edit in the Timeline", "在时间轴中编辑"),
("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常退出。"),
("Effects", "效果"),
("Effects & Presets options", "效果和预设选项"),
@@ -2469,7 +2538,6 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
("Duration:", "持續時間:"),
("Edit Gradient…", "編輯漸變…"),
("Edit Target: {}", "編輯目標:{}"),
("Edit in the Timeline", "在時間軸中編輯"),
("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常結束。"),
("Effects", "效果"),
("Effects & Presets options", "效果和預設集選項"),
@@ -3531,7 +3599,6 @@ const UKRAINIAN: &[(&str, &str)] = &[
("Duration:", "Тривалість:"),
("Edit Gradient…", "Редагувати градієнт…"),
("Edit Target: {}", "Ціль редагування: {}"),
("Edit in the Timeline", "Редагувати на шкалі часу"),
("EffectCraft didn't quit normally last time.", "Минулого разу EffectCraft завершив роботу некоректно."),
("Effects", "Ефекти"),
("Effects & Presets options", "Параметри ефектів і наборів"),
@@ -4095,7 +4162,6 @@ const PORTUGUESE: &[(&str, &str)] = &[
("Duration:", "Duração:"),
("Edit Gradient…", "Editar gradiente…"),
("Edit Target: {}", "Editar destino: {}"),
("Edit in the Timeline", "Editar na linha do tempo"),
("EffectCraft didn't quit normally last time.", "O EffectCraft não foi encerrado normalmente da última vez."),
("Effects", "Efeitos"),
("Effects & Presets options", "Opções de Efeitos e predefinições"),
@@ -4858,7 +4924,6 @@ const POLISH: &[(&str, &str)] = &[
("Duration:", "Czas trwania:"),
("Edit Gradient…", "Edytuj gradient…"),
("Edit Target: {}", "Cel edycji: {}"),
("Edit in the Timeline", "Edytuj na osi czasu"),
("EffectCraft didn't quit normally last time.", "EffectCraft nie zakończył się poprawnie ostatnim razem."),
("Effects & Presets options", "Opcje efektów i ustawień"),
("Copy Presets Folder Path", "Kopiuj ścieżkę folderu ustawień"),
@@ -5603,7 +5668,6 @@ const CZECH: &[(&str, &str)] = &[
("Duration:", "Délka trvání:"),
("Edit Gradient…", "Upravit přechod…"),
("Edit Target: {}", "Upravit cíl: {}"),
("Edit in the Timeline", "Upravit na časové ose"),
("EffectCraft didn't quit normally last time.", "EffectCraft nebyl při posledním použití ukončen běžným způsobem."),
("Effects", "Efekty"),
("Effects & Presets options", "Možnosti efektů a přednastavení"),
@@ -6171,7 +6235,6 @@ const SPANISH: &[(&str, &str)] = &[
("Duration:", "Duración:"),
("Edit Gradient…", "Editar degradado…"),
("Edit Target: {}", "Destino de la edición: {}"),
("Edit in the Timeline", "Editar en la cronología"),
("EffectCraft didn't quit normally last time.", "EffectCraft no se cerró normalmente la última vez."),
("Effects", "Efectos"),
("Effects & Presets options", "Opciones de Efectos y ajustes preestablecidos"),
@@ -6723,7 +6786,6 @@ const GEORGIAN: &[(&str, &str)] = &[
("Duration:", "ხანგრძლივობა:"),
("Edit Gradient…", "გრადიენტის რედაქტირება…"),
("Edit Target: {}", "რედაქტირების სამიზნე: {}"),
("Edit in the Timeline", "რედაქტირება დროის ხაზზე"),
("EffectCraft didn't quit normally last time.", "EffectCraft ბოლოს ნორმალურად არ დახურულა."),
("Effects", "ეფექტები"),
("Effects & Presets options", "ეფექტებისა და პრესეტების პარამეტრები"),
+7
View File
@@ -112,6 +112,8 @@ pub enum Icon {
Fullscreen,
Channel,
Exposure,
/// Stroke width: three bars, thin to thick.
StrokeWidth,
// links
Chat,
Globe,
@@ -548,6 +550,11 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
}
pen.circle(8.0, 8.0, 4.2);
}
StrokeWidth => {
pen.rect_fill(2.5, 3.0, 13.5, 3.75);
pen.rect_fill(2.5, 6.5, 13.5, 8.0);
pen.rect_fill(2.5, 10.5, 13.5, 13.0);
}
Hamburger => {
for y in [4.5, 8.0, 11.5] {
pen.line(&[(3.0, y), (13.0, y)]);
+11
View File
@@ -7,6 +7,17 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub(crate) mod a11y;
/// EffectCraft as craft-update knows it, with its 128 px icon and accent colour: the update
/// prompt shows both, and an AppImage's menu entry gets the icon.
#[cfg(not(target_arch = "wasm32"))]
pub const UPDATE_APP: craft_update::app::App = craft_update::app::App {
icon_png: Some(include_bytes!("../../../assets/app-icon/hicolor/128x128/apps/ai.storyteller.effectcraft.png")),
accent: Some([0xe0, 0x36, 0x8f]),
..craft_update::suite::EFFECTCRAFT
};
pub mod audio;
pub mod automation;
pub mod bench;
+3
View File
@@ -125,6 +125,9 @@ pub(crate) fn entry_enabled(app: &EffectcraftApp, e: &MenuEntry) -> bool {
if e.command == "edit.clear" && app.ui.focused == PanelKind::RenderQueue {
return !app.session.is_rendering() && !app.session.project.render_queue.is_empty();
}
if e.command == "file.close" && super::invoke::no_params(&e.params) && app.ui.focused == PanelKind::RenderQueue {
return !app.ui.dock.locked;
}
if e.command == "window.panel" {
return true;
}
+21 -3
View File
@@ -115,7 +115,7 @@ fn reveal_targets(app: &EffectcraftApp) -> Vec<u64> {
/// The arrow keys nudge the selected layers 1 pixel at the viewer's magnification (`up`,
/// `down`, `left`, `right`), 10 with Shift (`up10`…), as After Effects does, so zoomed in
/// they move by fractions of a comp pixel. From any panel except Project (which owns the
/// they move by fractions of a comp pixel. From any panel except Project, Effects & Presets and Render Queue (which own the
/// arrow keys for its own keyboard navigation, #961); not while typing, with a dialog or
/// menu open. With mask points selected the arrows move the points instead (#1288).
fn nudge(app: &mut EffectcraftApp, ctx: &egui::Context, dir: &str) -> Result<Value, String> {
@@ -131,7 +131,10 @@ fn nudge(app: &mut EffectcraftApp, ctx: &egui::Context, dir: &str) -> Result<Val
_ => return Err(format!("unknown nudge `{dir}`")),
};
let st = &app.session.state;
if app.ui.focused == PanelKind::Project || (st.selected_layers.is_empty() && st.selected_vertices.is_empty()) || st.text_edit.is_some() {
if matches!(app.ui.focused, PanelKind::Project | PanelKind::EffectsPresets | PanelKind::RenderQueue)
|| (st.selected_layers.is_empty() && st.selected_vertices.is_empty())
|| st.text_edit.is_some()
{
return Ok(Value::Null);
}
// Screen pixels per comp pixel.
@@ -168,7 +171,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 +226,21 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
_ => {}
}
}
// Close follows panel focus too: with the Render Queue active, Cmd+W closes that panel and
// leaves the active composition open (it used to close the composition next to it).
if id == "file.close" && no_params(&params) && app.ui.focused == PanelKind::RenderQueue {
app.apply_dock(craft_ui::docking::Action::Close { panel: PanelKind::RenderQueue })?;
// The focus goes to a panel that is still there, so the next Close acts on the composition
// instead of on the queue that is gone.
let next = [PanelKind::Timeline, PanelKind::Composition, PanelKind::Project].into_iter().find(|p| panel_shown(app, *p)).unwrap_or(PanelKind::Project);
app.show_panel(next);
app.focus_panel(ctx, next);
// Activating a passive viewer emits OpenComp, which raises Timeline. Finish that routing
// before restoring the destination panel, including when they share the same tab group.
app.handle_events(ctx);
app.show_panel(next);
return Ok(Value::Null);
}
// Edit ▸ Paste with SVG markup on the system clipboard pastes it as a shape layer (After
// Effects 26.3). Copying in the app replaces the system clipboard with a note naming what
// was copied, so SVG there was copied after anything copied in the app.
+10 -3
View File
@@ -136,8 +136,8 @@ pub fn chord_matches(session: &effectcraft_engine::Session, command: &str, key:
bindings(session).into_iter().any(|(m, k, id, _)| id == command && k == key && mods_match(m, mods))
}
/// Whether a Deselect All chord matched by [`chord_matches`] may run while a text editor holds
/// the keyboard. Chords carrying Cmd/Ctrl/Alt are command chords (the editor treats them as
/// Whether a Deselect All or Duplicate chord matched by [`chord_matches`] may run while a text
/// editor holds the keyboard. Chords carrying Cmd/Ctrl/Alt are command chords (the editor treats them as
/// such for undo and the rest), and function keys never type, so those forward from the editor;
/// a chord that could instead produce a character — a bare or Shift-only letter, digit or
/// punctuation key, as from a pathological bare-letter rebinding — stays with the editor so
@@ -324,6 +324,11 @@ fn numpad_star_marker(app: &mut EffectcraftApp, ctx: &egui::Context) {
}
}
/// Panels that handle Enter themselves (rename, apply, open the row).
fn panel_owns_enter(kind: PanelKind) -> bool {
matches!(kind, PanelKind::Project | PanelKind::EffectsPresets | PanelKind::RenderQueue)
}
fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only: bool) {
let start = modifiers_at_start(ctx);
if !tab_only && app.dialog.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
@@ -378,7 +383,9 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only
continue;
}
// Enter in the Project panel renames the selected item (handled by the panel).
if key == egui::Key::Enter && !mods.any() && app.ui.focused == PanelKind::Project {
// In Effects & Presets and the Render Queue Enter applies or opens the row (handled by the
// panel), not the layer rename.
if key == egui::Key::Enter && !mods.any() && panel_owns_enter(app.ui.focused) {
continue;
}
// The Project panel has its own selection; a previously selected Timeline layer
+169
View File
@@ -418,6 +418,175 @@ fn tga_is_available_in_import_replace_and_proxy_dialogs() {
}
}
/// Cmd+W with the Render Queue focused closes the queue; the composition next to it stays
/// open (it used to be closed, and the queue stayed).
#[test]
fn cmd_w_in_the_render_queue_closes_the_queue_not_the_comp() {
let ctx = egui::Context::default();
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
let a = app.session.execute("comp.new", json!({"name": "A", "duration": 4})).unwrap()["comp"].as_u64();
app.show_panel(PanelKind::RenderQueue);
assert_eq!(app.ui.focused, PanelKind::RenderQueue);
invoke(&mut app, &ctx, "file.close", json!({})).unwrap();
assert!(!app.ui.dock.is_visible(PanelKind::RenderQueue));
assert_eq!(app.session.state.active_comp.map(|c| c.0), a);
assert_eq!(app.session.state.open_comps.len(), 1);
// The focus left the closed queue: the next Close closes the composition.
assert_ne!(app.ui.focused, PanelKind::RenderQueue);
invoke(&mut app, &ctx, "file.close", json!({})).unwrap();
assert_eq!(app.session.state.open_comps.len(), 0, "a second Close acts on the composition");
}
#[test]
fn file_close_is_available_for_an_empty_render_queue_without_a_comp() {
for params in [Value::Null, json!({})] {
let ctx = egui::Context::default();
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
assert!(app.session.active_comp().is_none());
app.show_panel(PanelKind::RenderQueue);
assert!(menu_items(&app).iter().find(|item| item.id == "file.close").unwrap().enabled);
invoke(&mut app, &ctx, "file.close", params).unwrap();
assert!(!app.ui.dock.is_open(PanelKind::RenderQueue));
}
}
#[test]
fn closing_the_render_queue_reveals_the_panel_receiving_focus() {
for maximized in [false, true] {
let ctx = egui::Context::default();
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
app.close_panel(PanelKind::Timeline);
app.close_panel(PanelKind::Composition);
app.show_panel(PanelKind::EffectControls);
assert!(!app.ui.dock.is_visible(PanelKind::Project));
app.show_panel(PanelKind::RenderQueue);
if maximized {
app.apply_dock(craft_ui::docking::Action::Float { panel: PanelKind::RenderQueue, rect: [50.0, 60.0, 400.0, 200.0] }).unwrap();
app.toggle_maximize(PanelKind::EffectControls);
app.ui.focused = PanelKind::RenderQueue;
assert!(app.ui.dock.is_floating(PanelKind::RenderQueue));
}
invoke(&mut app, &ctx, "file.close", json!({})).unwrap();
assert_eq!(app.ui.focused, PanelKind::Project);
assert!(app.ui.dock.is_visible(app.ui.focused), "focus must belong to a front tab");
assert_eq!(app.ui.maximized, None, "the focused docked panel must be on screen");
}
}
/// Closing a floating queue must focus the viewer that is actually shown, retaining maximization.
#[test]
fn closing_a_floating_queue_preserves_the_maximized_composition() {
let mut session = effectcraft_engine::Session::default();
session.execute("comp.new", json!({"name": "A", "width": 32, "height": 32, "duration": 2})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_| EffectcraftApp::new(session));
h.run_steps(3);
let ctx = h.ctx.clone();
let app = h.state_mut();
app.show_panel(PanelKind::Composition);
app.show_panel(PanelKind::RenderQueue);
app.apply_dock(craft_ui::docking::Action::Float { panel: PanelKind::RenderQueue, rect: [50.0, 60.0, 400.0, 200.0] }).unwrap();
app.toggle_maximize(PanelKind::Composition);
app.ui.focused = PanelKind::RenderQueue;
assert!(app.ui.dock.is_visible(PanelKind::Timeline), "the hidden underlying tab triggers the regression");
assert!(!panel_shown(app, PanelKind::Timeline));
invoke(app, &ctx, "file.close", json!({})).unwrap();
h.run_steps(3);
assert_eq!(h.state().ui.focused, PanelKind::Composition);
assert_eq!(h.state().ui.maximized, Some(PanelKind::Composition));
assert!(!h.state().ui.dock.is_open(PanelKind::RenderQueue));
assert_eq!(h.state().session.state.open_comps.len(), 1);
}
#[test]
fn queue_close_preserves_state_when_workspace_is_locked() {
let ctx = egui::Context::default();
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
app.session.execute("comp.new", json!({"name": "A", "duration": 2})).unwrap();
app.show_panel(PanelKind::RenderQueue);
app.ui.dock.locked = true;
let workspace = app.ui.dock.clone();
let comp = app.session.active_comp_id();
for _ in 0..2 {
assert!(invoke(&mut app, &ctx, "file.close", Value::Null).is_err());
assert_eq!(app.ui.dock, workspace);
assert_eq!(app.ui.focused, PanelKind::RenderQueue);
assert_eq!(app.session.active_comp_id(), comp);
}
assert!(!menu_items(&app).iter().find(|item| item.id == "file.close").unwrap().enabled);
app.ui.dock.locked = false;
invoke(&mut app, &ctx, "file.close", Value::Null).unwrap();
assert!(!app.ui.dock.is_open(PanelKind::RenderQueue));
}
#[test]
fn queue_close_preserves_viewer_command_target() {
let ctx = egui::Context::default();
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
let a = app.session.execute("comp.new", json!({"name": "A", "duration": 2})).unwrap()["comp"].as_u64().unwrap();
let b = app.session.execute("comp.new", json!({"name": "B", "duration": 2})).unwrap()["comp"].as_u64().unwrap();
app.show_panel(PanelKind::Viewer(1));
app.ui.viewers.insert(0, Some(a));
app.ui.viewers.insert(1, Some(b));
app.ui.active_viewer = 1;
app.apply_dock(craft_ui::docking::Action::Move {
panel: PanelKind::Timeline,
anchor: PanelKind::Composition,
placement: craft_ui::docking::Placement::Tab { before: None },
})
.unwrap();
app.close_panel(PanelKind::Timeline);
app.show_panel(PanelKind::Composition);
app.show_panel(PanelKind::RenderQueue);
assert_eq!(app.session.active_comp_id().map(|id| id.0), Some(b));
invoke(&mut app, &ctx, "file.close", json!({})).unwrap();
assert_eq!(app.ui.focused, PanelKind::Composition);
assert_eq!(app.ui.active_viewer, 0);
assert_eq!(app.session.active_comp_id().map(|id| id.0), Some(a));
invoke(&mut app, &ctx, "file.close", json!({})).unwrap();
assert!(!app.session.state.open_comps.iter().any(|id| id.0 == a));
assert!(app.session.state.open_comps.iter().any(|id| id.0 == b));
}
#[test]
fn queue_close_keeps_passive_viewer_focus_across_frames() {
let mut session = effectcraft_engine::Session::default();
let a = session.execute("comp.new", json!({"name": "A", "width": 32, "height": 32, "duration": 2})).unwrap()["comp"].as_u64().unwrap();
let b = session.execute("comp.new", json!({"name": "B", "width": 32, "height": 32, "duration": 2})).unwrap()["comp"].as_u64().unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_| EffectcraftApp::new(session));
h.run_steps(3);
let ctx = h.ctx.clone();
let app = h.state_mut();
app.show_panel(PanelKind::Viewer(1));
app.ui.viewers.insert(0, Some(a));
app.ui.viewers.insert(1, Some(b));
app.ui.active_viewer = 1;
app.apply_dock(craft_ui::docking::Action::Move {
panel: PanelKind::Timeline,
anchor: PanelKind::Composition,
placement: craft_ui::docking::Placement::Tab { before: None },
})
.unwrap();
app.close_panel(PanelKind::Timeline);
app.show_panel(PanelKind::Composition);
app.show_panel(PanelKind::RenderQueue);
invoke(app, &ctx, "file.close", Value::Null).unwrap();
h.run_steps(4);
let app = h.state_mut();
assert_eq!(app.ui.focused, PanelKind::Composition);
assert_eq!(app.ui.active_viewer, 0);
assert_eq!(app.session.active_comp_id().map(|id| id.0), Some(a));
assert!(app.ui.dock.is_visible(PanelKind::Composition));
assert!(!app.ui.dock.is_open(PanelKind::RenderQueue));
invoke(app, &ctx, "file.close", json!({})).unwrap();
assert!(!app.session.state.open_comps.iter().any(|id| id.0 == a));
assert!(app.session.state.open_comps.iter().any(|id| id.0 == b));
// A later explicit Open still routes normally, rather than inheriting a deferred focus override.
invoke(app, &ctx, "comp.open", json!({"comp": b})).unwrap();
h.run_steps(3);
assert!(h.state().ui.dock.is_visible(PanelKind::Timeline));
assert_eq!(h.state().session.active_comp_id().map(|id| id.0), Some(b));
}
#[test]
fn menu_items_cover_the_tree() {
assert_eq!(menus().last(), Some(&"Help"));
+7 -2
View File
@@ -124,8 +124,10 @@ impl PresetRef {
/// Layers carried by a Project item drag. Dragging a selected item uses the
/// complete selection, while dragging an unselected item uses only that item.
/// Folders and data files are not composition layers.
pub(crate) fn project_drop_items(app: &EffectcraftApp, dragged: u64) -> Vec<u64> {
/// Folders and data files are not composition layers, and the composition the
/// drop lands in is left out of a selection: it cannot contain itself, and one
/// such item would make the engine refuse the whole drop (#2589).
pub(crate) fn project_drop_items(app: &EffectcraftApp, dragged: u64, target: Option<effectcraft_engine::project::ItemId>) -> Vec<u64> {
use effectcraft_engine::project::{FootageKind, ItemId, ItemKind};
let selected = &app.session.state.project_selection;
@@ -139,6 +141,9 @@ pub(crate) fn project_drop_items(app: &EffectcraftApp, dragged: u64) -> Vec<u64>
candidates
.into_iter()
.filter(|id| {
if Some(ItemId(*id)) == target {
return false;
}
app.session.project.item(ItemId(*id)).is_some_and(|item| match &item.kind {
ItemKind::Comp(_) | ItemKind::Solid(_) => true,
ItemKind::Footage(f) => f.kind != FootageKind::Data,
+1 -1
View File
@@ -45,7 +45,7 @@ fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &st
fn checkbox(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, text: &str, on: bool) -> bool {
let t = app.tokens;
let r = Rect::from_min_size(at, vec2(16.0, 16.0));
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::unique(("animtool-cb", auto))).clicked();
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::unique(("animtool-cb", auto))).changed();
ui.painter().text(pos2(r.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text);
app.auto.add(auto, r, text);
if clicked { !on } else { on }
+3 -3
View File
@@ -310,7 +310,7 @@ fn tab_body(
let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.width).range(4..=30000).suffix(" px")));
auto.add("dialog.comp.width", r.rect, "Width");
let lbl = tr_args("Lock Aspect Ratio to {}", &[&(aspect_label(ow as f64, oh as f64))]);
let r = ui.checkbox(&mut d.lock_aspect, lbl);
let r = crate::widgets::labelled_checkbox(ui, &mut d.lock_aspect, lbl);
auto.add("dialog.comp.lockAspect", r.rect, "Lock Aspect Ratio");
if d.lock_aspect && ow != d.width && ow > 0 {
d.height = ((d.width as f64) * oh as f64 / ow as f64).round().max(4.0) as u32;
@@ -426,11 +426,11 @@ fn tab_body(
});
ui.end_row();
ui.label("");
let r = ui.checkbox(&mut d.preserve_frame_rate, tr("Preserve frame rate when nested or in render queue"));
let r = crate::widgets::labelled_checkbox(ui, &mut d.preserve_frame_rate, tr("Preserve frame rate when nested or in render queue"));
auto.add("dialog.comp.preserveFrameRate", r.rect, "Preserve frame rate when nested or in render queue");
ui.end_row();
ui.label("");
let r = ui.checkbox(&mut d.preserve_resolution, tr("Preserve resolution when nested"));
let r = crate::widgets::labelled_checkbox(ui, &mut d.preserve_resolution, tr("Preserve resolution when nested"));
auto.add("dialog.comp.preserveResolution", r.rect, "Preserve resolution when nested");
ui.end_row();
ui.label(egui::RichText::new(tr("Motion Blur")).strong());
+7 -3
View File
@@ -257,7 +257,7 @@ pub(crate) fn modal_fit_placed(ctx: &egui::Context, title: &str, size: egui::Vec
// The id stays keyed on the untranslated source, so switching language cannot move it.
let head = ui.label(egui::RichText::new(tr(title)).font(Tokens::semibold(15.0)).color(t.tab_text_active));
let row = Rect::from_x_y_ranges(ui.max_rect().x_range(), head.rect.y_range());
let grip = ui.interact(row, id.with("grip"), Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab);
let grip = ui.interact(row, id.with("grip"), Sense::DRAG).on_hover_cursor(egui::CursorIcon::Grab);
if grip.dragged() {
let moved = (offset + grip.drag_delta()).clamp(-room, room);
ui.ctx().data_mut(|d| d.insert_temp(offset_id, moved));
@@ -584,7 +584,11 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
ui.horizontal(|ui| {
let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut size[0]).range(1..=30000).suffix(" px")));
auto.add("dialog.solid.width", r.rect, "Width");
let r = ui.checkbox(&mut lock, tr_args("Lock Aspect Ratio to {}", &[&(super::comp_settings::aspect_label(ow as f64, oh as f64))]));
let r = crate::widgets::labelled_checkbox(
ui,
&mut lock,
tr_args("Lock Aspect Ratio to {}", &[&(super::comp_settings::aspect_label(ow as f64, oh as f64))]),
);
auto.add("dialog.solid.lockAspect", r.rect, "Lock Aspect Ratio");
});
ui.end_row();
@@ -640,7 +644,7 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
if editing.is_some() {
ui.add_space(8.0);
let r = ui
.add_enabled(shared, egui::Checkbox::new(&mut affect_all, tr("Affect all layers that use this solid")))
.add_enabled(shared, crate::widgets::DragCheckbox::new(&mut affect_all, tr("Affect all layers that use this solid")))
.on_disabled_hover_text(tr("Only this layer uses this solid."));
auto.add("dialog.solid.affectAll", r.rect, "Affect all layers that use this solid");
if shared && !affect_all {
+3 -3
View File
@@ -231,12 +231,12 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
});
ui.end_row();
ui.label(tr("Depth of Field"));
ui.checkbox(&mut d.dof, tr("Enable Depth of Field"));
crate::widgets::labelled_checkbox(ui, &mut d.dof, tr("Enable Depth of Field"));
ui.end_row();
ui.label(tr("Focus Distance"));
ui.horizontal(|ui| {
ui.add_enabled(!d.lock_to_zoom, crate::widgets::drag_value(egui::DragValue::new(&mut d.focus).range(0.0..=100000.0).suffix(" px")));
ui.checkbox(&mut d.lock_to_zoom, tr("Lock to Zoom"));
crate::widgets::labelled_checkbox(ui, &mut d.lock_to_zoom, tr("Lock to Zoom"));
});
ui.end_row();
ui.label(tr("Aperture"));
@@ -354,7 +354,7 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
}
if casts_shadows(d.kind) {
ui.label(tr("Shadows"));
let cb = ui.checkbox(&mut d.casts_shadows, tr("Casts Shadows"));
let cb = crate::widgets::labelled_checkbox(ui, &mut d.casts_shadows, tr("Casts Shadows"));
app.auto.add_response("dialog.light.casts", &cb, "Casts Shadows");
ui.end_row();
ui.label(tr("Shadow Darkness"));
@@ -93,8 +93,19 @@ impl PropCx<'_> {
self.t.for_value(self.prop.has_expression())
}
/// Queues the `prop.set` of one edit; consecutive edits of the parameter (a scrub) are one undo step.
fn set(&self, actions: &mut Actions, v: serde_json::Value) {
let (layer, uid, merge, scrub) = (self.layer, self.uid, format!("ec-{}", self.uid), self.scrub);
self.push(actions, v, Some(format!("ec-{}", self.uid)));
}
/// Queues the `prop.set` of a discrete choice (a pop-up pick, a check box): its own undo step,
/// never merged with the parameter's previous edit (#2152).
fn set_step(&self, actions: &mut Actions, v: serde_json::Value) {
self.push(actions, v, None);
}
fn push(&self, actions: &mut Actions, v: serde_json::Value, merge: Option<String>) {
let (layer, uid, scrub) = (self.layer, self.uid, self.scrub);
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub})))
}
@@ -240,7 +251,7 @@ fn value_mask(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &
}
app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name);
if let Some(ni) = widgets::popup_menu(ui, pop, dr.left_bottom(), &opts, Some(i)) {
cx.set(actions, json!(ni as f64));
cx.set_step(actions, json!(ni as f64));
}
}
@@ -367,8 +378,8 @@ 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() {
cx.set(actions, json!(!b));
if widgets::checkbox(ui, cr, *b, &t, egui::Id::unique(("ec-b", uid))).changed() {
cx.set_step(actions, json!(!b));
}
p.text(pos2(cr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text_dim);
app.auto.add(&format!("effectControls.prop.{uid}.value"), cr, &prop.name);
@@ -388,7 +399,7 @@ fn value_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value:
}
app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name);
if let Some(ni) = widgets::popup_menu(ui, pop, dr.left_bottom(), options, Some(*i as usize)) {
cx.set(actions, json!(ni));
cx.set_step(actions, json!(ni));
}
}
}
@@ -402,7 +413,7 @@ fn value_layer_ref(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, val
if let Some(comp) = comp {
let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(((r.max.x - vx) * 0.55).clamp(90.0, 170.0), 18.0));
if let Some(chosen) = widgets::layer_picker(ui, dr, &comp.layers, *l, &t, egui::Id::unique(("ec-l", uid))) {
cx.set(actions, chosen.map(|id| json!(id)).unwrap_or(serde_json::Value::Null));
cx.set_step(actions, chosen.map(|id| json!(id)).unwrap_or(serde_json::Value::Null));
}
app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name);
// Source / Masks / Effects & Masks (the hidden companion parameter).
+314 -30
View File
@@ -10,6 +10,7 @@ use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
use serde_json::json;
use std::sync::Arc;
use crate::a11y;
use crate::icons::{self, Icon};
use crate::panels::{DragPayload, PresetRef};
use crate::theme::Tokens;
@@ -107,12 +108,70 @@ fn highlighted(p: &egui::Painter, pos: egui::Pos2, name: &str, query: &str, t: &
p.text(pos, Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text);
}
#[derive(Clone)]
enum Apply {
Effect(String),
/// A text, built-in (Transitions…) or user file animation preset.
Preset(PresetRef),
}
/// One row of the list as drawn this pass, including the rows scrolled out of view: what the
/// arrow keys, Home / End and Enter work on (the rows are painted by hand, so this is the list).
struct NavRow {
id: egui::Id,
/// The favourite star's widget, for an effect row.
star: Option<egui::Id>,
parent: Option<egui::Id>,
/// Where the row starts in the scrolled content.
top: f32,
/// A folder: (its key, open, forced open by a search).
folder: Option<(String, bool, bool)>,
/// What Enter applies, for an effect or preset row.
apply: Option<Apply>,
}
/// The list's keyboard state: the rows drawn, and the row the keys work from (the "cursor":
/// selected, and the one Tab stop of the list).
struct Nav {
rows: Vec<NavRow>,
/// The open folders on the way down to the row being drawn, by level.
stack: Vec<egui::Id>,
cursor: Option<egui::Id>,
/// The scroll offset and the list's top, to place rows in the content.
offset: f32,
list_top: f32,
}
impl Nav {
/// The row `id` is the list's Tab stop: the cursor row, or the first row while there is none.
fn tab_stop(&self, id: egui::Id) -> bool {
match self.cursor {
Some(c) => c == id,
None => self.rows.is_empty(),
}
}
/// Record the row drawn at `y`; returns whether it is the cursor row.
fn push(&mut self, id: egui::Id, y: f32, level: usize, folder: Option<(String, bool, bool)>, apply: Option<Apply>) -> bool {
let parent = level.checked_sub(2).and_then(|i| self.stack.get(i)).copied();
if folder.is_some() {
self.stack.truncate(level.saturating_sub(1));
self.stack.push(id);
}
self.rows.push(NavRow { id, star: None, parent, top: y - self.list_top + self.offset, folder, apply });
self.cursor == Some(id)
}
}
fn cursor_id() -> egui::Id {
egui::Id::unique("fx-cursor")
}
/// The accessible name of a folder: what the row shows, in the interface language.
fn folder_name(label: &str) -> &str {
tr(label)
}
/// A folder row; returns whether it is open.
#[allow(clippy::too_many_arguments)]
fn folder_row(
@@ -121,8 +180,10 @@ fn folder_row(
lp: &egui::Painter,
list: Rect,
y: &mut f32,
nav: &mut Nav,
key: &str,
label: &str,
level: usize,
indent: f32,
forced_open: bool,
icon: Option<Icon>,
@@ -130,12 +191,18 @@ fn folder_row(
let t = app.tokens;
let open = forced_open || app.ui.effects_open.contains(key);
let r = Rect::from_min_size(pos2(list.min.x, *y), vec2(list.width(), ROW_H));
let id = egui::Id::unique(("fxcat", key));
let tab = nav.tab_stop(id);
let selected = nav.push(id, *y, level, Some((key.to_string(), open, forced_open)), None);
*y += ROW_H;
if r.max.y < list.min.y || r.min.y > list.max.y {
return open;
}
let resp = ui.interact(r.intersect(list), egui::Id::unique(("fxcat", key)), Sense::click());
if resp.hovered() {
let shown = folder_name(label);
let resp = ui.interact(r.intersect(list), id, if tab { Sense::click() } else { Sense::CLICK });
if selected {
lp.rect_filled(r, 0.0, t.row_selected);
} else if resp.hovered() {
lp.rect_filled(r, 0.0, t.hover);
}
let tw = Rect::from_center_size(pos2(r.min.x + 12.0 + indent, r.center().y), vec2(12.0, 12.0));
@@ -146,18 +213,44 @@ fn folder_row(
Some(i) => icons::paint(lp, ic, i, t.text_dim),
None => icons::paint(lp, ic, Icon::Folder, Color32::from_rgb(0xc8, 0xb0, 0x58)),
}
lp.text(pos2(r.min.x + 40.0 + indent, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text);
lp.text(pos2(r.min.x + 40.0 + indent, r.center().y), Align2::LEFT_CENTER, shown, Tokens::ui(12.0), t.text);
app.auto.add(&format!("effects.category.{key}"), r, label);
if resp.clicked() && !forced_open {
if open {
app.ui.effects_open.remove(key);
} else {
app.ui.effects_open.insert(key.to_string());
a11y::tree_item(&resp, shown, selected, level, Some(open));
row_focus(app, ui, lp, &resp, r, id);
if resp.clicked() {
nav_click(ui.ctx(), app, id);
if !forced_open {
if open {
app.ui.effects_open.remove(key);
} else {
app.ui.effects_open.insert(key.to_string());
}
}
}
open
}
/// What every row does with the keyboard focus: the focus ring, the panel taking the focus, the
/// list owning its keys, and moving the focus here when the arrow keys asked for it.
fn row_focus(app: &mut EffectcraftApp, ui: &egui::Ui, lp: &egui::Painter, resp: &egui::Response, r: Rect, id: egui::Id) {
let ctx = ui.ctx();
if a11y::take_pending_focus(ctx, "fx", id) {
resp.request_focus();
}
if resp.gained_focus() {
app.ui.focused = crate::dock::PanelKind::EffectsPresets;
}
if resp.has_focus() {
lp.rect_stroke(r.shrink(0.5), 0.0, Stroke::new(1.0, app.tokens.accent), egui::StrokeKind::Inside);
}
}
/// A click on a row moves the cursor there and gives the panel the keyboard.
fn nav_click(ctx: &egui::Context, app: &mut EffectcraftApp, id: egui::Id) {
ctx.data_mut(|d| d.insert_temp(cursor_id(), id));
app.ui.focused = crate::dock::PanelKind::EffectsPresets;
}
/// Number of visible rows before Animation Presets in the current panel state.
fn rows_before_presets(favorites: usize, recent: usize, searching: bool, favorites_open: bool, recent_open: bool) -> usize {
let mut rows = 0usize;
@@ -184,7 +277,9 @@ fn effect_row(
lp: &egui::Painter,
list: Rect,
y: &mut f32,
nav: &mut Nav,
e: &'static effectcraft_engine::effects::EffectSpec,
level: usize,
indent: f32,
query: &str,
auto_prefix: &str,
@@ -193,13 +288,22 @@ fn effect_row(
let t = app.tokens;
let ctx = ui.ctx().clone();
let r = Rect::from_min_size(pos2(list.min.x, *y), vec2(list.width(), ROW_H));
let id = egui::Id::unique((auto_prefix, e.id));
let star_id = egui::Id::unique((auto_prefix, e.id, "star"));
let tab = nav.tab_stop(id);
let selected = nav.push(id, *y, level, None, Some(Apply::Effect(e.id.to_string())));
if let Some(row) = nav.rows.last_mut() {
row.star = Some(star_id);
}
*y += ROW_H;
if r.max.y < list.min.y || r.min.y > list.max.y {
return;
}
let star = Rect::from_center_size(pos2(r.max.x - 38.0, r.center().y), vec2(16.0, 16.0));
let resp = ui.interact(r.intersect(list), egui::Id::unique((auto_prefix, e.id)), Sense::click_and_drag());
if resp.hovered() {
let resp = ui.interact(r.intersect(list), id, if tab { Sense::click_and_drag() } else { Sense::CLICK | Sense::DRAG });
if selected {
lp.rect_filled(r, 0.0, t.row_selected);
} else if resp.hovered() {
lp.rect_filled(r, 0.0, t.hover);
}
icons::paint(lp, Rect::from_center_size(pos2(r.min.x + 44.0 + indent, r.center().y), vec2(12.0, 12.0)), Icon::Fx, t.text_dim);
@@ -211,20 +315,32 @@ fn effect_row(
lp.text(pos2(r.max.x - 10.0, r.center().y), Align2::RIGHT_CENTER, "32", Tokens::ui(9.5), t.text_faint);
}
app.auto.add(&format!("{auto_prefix}.{}", e.id), r, e.name);
// Favourite star (always shown when starred, else on hover).
a11y::tree_item(&resp, &shown, selected, level, None);
row_focus(app, ui, lp, &resp, r, id);
// Favourite star (always shown when starred, else on hover or with the keyboard focus).
let fav = app.session.prefs.is_effect_favorite(e.id);
let sresp = ui.interact(star.intersect(list), egui::Id::unique((auto_prefix, e.id, "star")), Sense::click());
if fav || resp.hovered() || sresp.hovered() {
let sresp = ui.interact(star.intersect(list), star_id, if tab { Sense::click() } else { Sense::CLICK });
if fav || resp.hovered() || sresp.hovered() || sresp.has_focus() {
let gold = Color32::from_rgb(0xe8, 0xc0, 0x40);
filled_star(lp, star.center(), 6.0, fav.then_some(gold), if fav || sresp.hovered() { gold } else { t.text_faint });
}
app.auto.add(&format!("{auto_prefix}.{}.star", e.id), star, if fav { "Remove from Favorites" } else { "Add to Favorites" });
a11y::button(&sresp, &format!("{}: {shown}", tr(if fav { "Remove from favorites" } else { "Add to favorites" })), true);
if sresp.gained_focus() {
app.ui.focused = crate::dock::PanelKind::EffectsPresets;
}
if sresp.has_focus() {
widgets::focus_outline(ui, &sresp, &t);
}
if sresp.clicked() {
// Kept in the settings, so the stars are there next time (#1692).
app.session.prefs.toggle_effect_favorite(e.id);
app.session.save_prefs();
return;
}
if resp.clicked() {
nav_click(&ctx, app, id);
}
if resp.double_clicked() {
*apply = Some(Apply::Effect(e.id.to_string()));
}
@@ -241,6 +357,7 @@ fn preset_row(
lp: &egui::Painter,
list: Rect,
y: &mut f32,
nav: &mut Nav,
name: &str,
auto_id: &str,
query: &str,
@@ -249,17 +366,27 @@ fn preset_row(
) {
let t = app.tokens;
let r = Rect::from_min_size(pos2(list.min.x, *y), vec2(list.width(), ROW_H));
let id = egui::Id::unique(("fxpreset", auto_id));
let tab = nav.tab_stop(id);
let selected = nav.push(id, *y, 3, None, Some(Apply::Preset(preset.clone())));
*y += ROW_H;
if r.max.y < list.min.y || r.min.y > list.max.y {
return;
}
let resp = ui.interact(r.intersect(list), egui::Id::unique(("fxpreset", auto_id)), Sense::click_and_drag());
if resp.hovered() {
let resp = ui.interact(r.intersect(list), id, if tab { Sense::click_and_drag() } else { Sense::CLICK | Sense::DRAG });
if selected {
lp.rect_filled(r, 0.0, t.row_selected);
} else if resp.hovered() {
lp.rect_filled(r, 0.0, t.hover);
}
icons::paint(lp, Rect::from_center_size(pos2(r.min.x + 58.0, r.center().y), vec2(12.0, 12.0)), Icon::Keyframe, t.text_dim);
highlighted(lp, pos2(r.min.x + 70.0, r.center().y), name, query, &t);
app.auto.add(auto_id, r, name);
a11y::tree_item(&resp, name, selected, 3, None);
row_focus(app, ui, lp, &resp, r, id);
if resp.clicked() {
nav_click(ui.ctx(), app, id);
}
if resp.drag_started() {
egui::DragAndDrop::set_payload(ui.ctx(), DragPayload::Preset { preset: preset.clone(), name: name.to_string() });
}
@@ -268,6 +395,120 @@ fn preset_row(
}
}
/// The list's keys. With a row focused (Tab, a screen reader): Up / Down move between rows,
/// Home / End go to the first / last, Page Up / Page Down a page, Right opens a folder (then steps
/// into it) and Left closes it (then steps out to it), Enter and Space apply the row or open /
/// close the folder. With the mouse's last click in the panel and no widget focused, the arrows
/// and Enter work the same, so the list is operable without a mouse either way. The focus follows
/// the cursor, so a screen reader reads each row as it is reached. Returns what to apply.
fn list_keys(app: &mut EffectcraftApp, ctx: &egui::Context, nav: &Nav, list: Rect) -> Option<Apply> {
use egui::Key;
// A cursor whose row is gone (a search hid it): the first row is the Tab stop again.
if let Some(c) = nav.cursor
&& !nav.rows.iter().any(|r| r.id == c)
{
ctx.data_mut(|d| d.remove::<egui::Id>(cursor_id()));
ctx.request_repaint();
}
let focused = ctx.memory(|m| m.focused());
let on_row = focused.is_some_and(|f| nav.rows.iter().any(|r| r.id == f || r.star == Some(f)));
let by_mouse = focused.is_none() && app.ui.focused == crate::dock::PanelKind::EffectsPresets;
if (!on_row && !by_mouse) || app.dialog.is_some() || widgets::any_menu_open(ctx) {
return None;
}
let keys: Vec<Key> = ctx.input(|i| {
i.events
.iter()
.filter_map(|e| match e {
egui::Event::Key { key, pressed: true, modifiers, .. } if !modifiers.any() => Some(*key),
_ => None,
})
.collect()
});
let page = ((list.height() / ROW_H).floor() as usize).saturating_sub(1).max(1);
// The row with the keyboard focus is where the keys start (the first Tab lands on a row
// before anything is selected); otherwise the cursor.
let focus_row = focused.and_then(|f| nav.rows.iter().find(|r| r.id == f || r.star == Some(f))).map(|r| r.id);
let mut cursor = focus_row.or_else(|| nav.cursor.filter(|c| nav.rows.iter().any(|r| r.id == *c)));
let mut apply = None;
let mut moved = false;
for key in keys {
let at = cursor.and_then(|c| nav.rows.iter().position(|r| r.id == c));
let last = nav.rows.len().checked_sub(1)?;
let to = match key {
Key::ArrowDown => Some(at.map_or(0, |a| a.saturating_add(1).min(last))),
Key::ArrowUp => Some(at.map_or(last, |a| a.saturating_sub(1))),
Key::Home if on_row => Some(0),
Key::End if on_row => Some(last),
Key::PageDown if on_row => Some(at.map_or(0, |a| a.saturating_add(page).min(last))),
Key::PageUp if on_row => Some(at.map_or(last, |a| a.saturating_sub(page))),
Key::ArrowRight | Key::ArrowLeft | Key::Enter | Key::Space if at.is_some() => {
let row = at.and_then(|a| nav.rows.get(a))?;
let toggle = |app: &mut EffectcraftApp, open: bool| {
if let Some((k, _, forced)) = &row.folder
&& !forced
{
if open {
app.ui.effects_open.insert(k.clone());
} else {
app.ui.effects_open.remove(k);
}
}
};
match (key, &row.folder) {
(Key::ArrowRight, Some((_, false, _))) => {
toggle(app, true);
None
}
(Key::ArrowRight, Some((_, true, _))) => at.map(|a| a.saturating_add(1).min(last)),
(Key::ArrowLeft, Some((_, true, false))) => {
toggle(app, false);
None
}
(Key::ArrowLeft, _) => row.parent.and_then(|p| nav.rows.iter().position(|r| r.id == p)),
(Key::Enter, Some((_, open, _))) | (Key::Space, Some((_, open, _))) if on_row || key == Key::Enter => {
toggle(app, !*open);
None
}
(Key::Enter, None) => {
apply = row.apply.clone();
None
}
(Key::Space, None) if on_row => {
apply = row.apply.clone();
None
}
_ => None,
}
}
_ => None,
};
if let Some(to) = to
&& let Some(row) = nav.rows.get(to)
{
cursor = Some(row.id);
moved = true;
}
}
if moved && let Some(c) = cursor {
ctx.data_mut(|d| d.insert_temp(cursor_id(), c));
// Scroll the row into view.
if let Some(row) = nav.rows.iter().find(|r| r.id == c) {
widgets::PanelScroll::reveal(ctx, egui::Id::unique("fx-scroll"), row.top, row.top + ROW_H, list.height());
}
app.ui.focused = crate::dock::PanelKind::EffectsPresets;
if on_row {
a11y::focus_next_pass(ctx, "fx", c);
}
ctx.request_repaint();
}
// The arrows are the list's, not egui's spatial focus moves.
if on_row {
ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None));
}
apply
}
/// User preset files, rescanned every few seconds (and on Refresh List).
fn user_presets(ctx: &egui::Context, force: bool, scan: impl FnOnce() -> Vec<PresetFile>) -> Arc<Vec<PresetFile>> {
let id = egui::Id::unique("fx-user-presets");
@@ -289,7 +530,9 @@ fn contents_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, ctx: &egui::Contex
// Contents menu.
let mr = Rect::from_center_size(pos2(rect.max.x - 18.0, sr.center().y), vec2(22.0, 22.0));
let mpop = egui::Id::unique("fx-contents-menu");
if widgets::icon_button(ui, mr, Icon::Hamburger, false, t, egui::Id::unique("fx-menu")).on_hover_text(tr("Effects & Presets options")).clicked() {
let menu_resp = widgets::icon_button(ui, mr, Icon::Hamburger, false, t, egui::Id::unique("fx-menu")).on_hover_text(tr("Effects & Presets options"));
a11y::menu_button(&menu_resp, tr("Effects & Presets options"), widgets::popup_is_open(ui, mpop));
if menu_resp.clicked() {
widgets::open_popup(ui, mpop);
}
app.auto.add("effects.menu", mr, "Effects & Presets options");
@@ -322,6 +565,12 @@ fn contents_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, ctx: &egui::Contex
ui.separator();
} else {
let resp = ui.selectable_label(false, label);
// The ✓ and the padding are the menu's drawing: say it as a state.
let checkable = matches!(i, 0 | 1 | 2 | 4 | 6 | 7);
a11y::menu_item(&resp, label.trim_start_matches('✓').trim(), checkable.then(|| label.starts_with('✓')));
if i == 0 {
widgets::focus_when_opened(ui, mpop, &resp);
}
if label == &copy_label {
app.auto.add("effects.copyPresetsFolderPath", resp.rect, "Copy Presets Folder Path");
}
@@ -336,6 +585,9 @@ fn contents_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, ctx: &egui::Contex
None
};
if let Some(i) = choice {
if widgets::by_keyboard(ui) {
menu_resp.request_focus();
}
let v = &mut app.ui.effects_view;
match i {
i if menu.get(i) == Some(&copy_label) => {
@@ -376,7 +628,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
let sr = Rect::from_min_size(rect.min + vec2(8.0, 8.0), vec2(rect.width() - 44.0, 24.0));
let mut q = app.ui.effects_search.clone();
widgets::search_field(ui, sr, &mut q, crate::i18n::tr("Search effects and presets"), &t);
let search_hint = tr("Search effects and presets");
let search = widgets::search_field(ui, sr, &mut q, search_hint, &t);
a11y::text_input(&search, search_hint, &q);
app.ui.effects_search = q.clone();
app.auto.add("effects.search", sr, "Search");
let force_scan = contents_menu(app, ui, &ctx, rect, sr, &t);
@@ -388,6 +642,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let searching = !query.is_empty();
let mut y = list.min.y - scroll.offset;
let mut apply: Option<Apply> = None;
let mut nav = Nav { rows: vec![], stack: vec![], cursor: ctx.data(|d| d.get_temp::<egui::Id>(cursor_id())), offset: scroll.offset, list_top: list.min.y };
let reg = effectcraft_engine::effects::all();
let keep = |e: &effectcraft_engine::effects::EffectSpec| {
depth_keeps(&view.depth, e)
@@ -406,17 +661,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
rows_before_presets(favs.len(), recent.len(), searching, app.ui.effects_open.contains("favorites"), app.ui.effects_open.contains("recent"));
scroll.offset = ROW_H * preceding_rows as f32;
y = list.min.y - scroll.offset;
nav.offset = scroll.offset;
}
if !favs.is_empty() && folder_row(app, ui, &lp, list, &mut y, "favorites", "Favorites", 0.0, searching, Some(Icon::Star)) {
if !favs.is_empty() && folder_row(app, ui, &lp, list, &mut y, &mut nav, "favorites", "Favorites", 1, 0.0, searching, Some(Icon::Star)) {
let mut favs = favs;
favs.sort_by_key(|e| e.name);
for e in favs {
effect_row(app, ui, &lp, list, &mut y, e, 0.0, &query, "effects.favorites", &mut apply);
effect_row(app, ui, &lp, list, &mut y, &mut nav, e, 2, 0.0, &query, "effects.favorites", &mut apply);
}
}
if !recent.is_empty() && !searching && folder_row(app, ui, &lp, list, &mut y, "recent", "Recently Used", 0.0, false, None) {
if !recent.is_empty() && !searching && folder_row(app, ui, &lp, list, &mut y, &mut nav, "recent", "Recently Used", 1, 0.0, false, None) {
for e in recent {
effect_row(app, ui, &lp, list, &mut y, e, 0.0, &query, "effects.recent", &mut apply);
effect_row(app, ui, &lp, list, &mut y, &mut nav, e, 2, 0.0, &query, "effects.recent", &mut apply);
}
}
@@ -435,21 +691,45 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let user = user_presets(&ctx, force_scan, || presets_dir().map(|d| scan_presets(&d)).unwrap_or_default());
let files: Vec<&PresetFile> = user.iter().filter(|f| query.is_empty() || f.name.to_lowercase().contains(&query)).collect();
let any = !text.is_empty() || !builtin.is_empty() || !files.is_empty();
if any && folder_row(app, ui, &lp, list, &mut y, "presets", "* Animation Presets", 0.0, searching, None) {
if !text.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/Text", "Text", 14.0, searching, None) {
if any && folder_row(app, ui, &lp, list, &mut y, &mut nav, "presets", "* Animation Presets", 1, 0.0, searching, None) {
if !text.is_empty() && folder_row(app, ui, &lp, list, &mut y, &mut nav, "presets/Text", "Text", 2, 14.0, searching, None) {
for (id, name) in text {
preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, PresetRef::Text(id.clone()), &mut apply);
preset_row(
app,
ui,
&lp,
list,
&mut y,
&mut nav,
&name,
&format!("effects.preset.text.{id}"),
&query,
PresetRef::Text(id.clone()),
&mut apply,
);
}
}
for cat in &categories {
if !folder_row(app, ui, &lp, list, &mut y, &format!("presets/{cat}"), cat, 14.0, searching, None) {
if !folder_row(app, ui, &lp, list, &mut y, &mut nav, &format!("presets/{cat}"), cat, 2, 14.0, searching, None) {
continue;
}
for (id, name, _) in builtin.iter().filter(|(_, _, c)| c == cat) {
preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, PresetRef::Builtin(id.clone()), &mut apply);
preset_row(
app,
ui,
&lp,
list,
&mut y,
&mut nav,
name,
&format!("effects.preset.builtin.{id}"),
&query,
PresetRef::Builtin(id.clone()),
&mut apply,
);
}
}
if !files.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/User", "User Presets", 14.0, searching, None) {
if !files.is_empty() && folder_row(app, ui, &lp, list, &mut y, &mut nav, "presets/User", "User Presets", 2, 14.0, searching, None) {
for f in files {
preset_row(
app,
@@ -457,6 +737,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
&lp,
list,
&mut y,
&mut nav,
&f.name,
&format!("effects.preset.file.{}", f.name),
&query,
@@ -473,7 +754,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let mut all: Vec<_> = reg.iter().filter(|e| keep(e)).collect();
all.sort_by_key(|e| e.name.to_lowercase());
for e in all {
effect_row(app, ui, &lp, list, &mut y, e, -16.0, &query, "effects.item", &mut apply);
effect_row(app, ui, &lp, list, &mut y, &mut nav, e, 1, -16.0, &query, "effects.item", &mut apply);
}
} else {
let mut cats: Vec<&str> = effectcraft_engine::effects::categories();
@@ -485,11 +766,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
// The folder draws the category in the interface language; `cat` stays the key (the
// open/closed state and the automation id are the registry's English name).
if !folder_row(app, ui, &lp, list, &mut y, cat, &crate::i18n::effects::category(cat), 0.0, searching, None) {
if !folder_row(app, ui, &lp, list, &mut y, &mut nav, cat, &crate::i18n::effects::category(cat), 1, 0.0, searching, None) {
continue;
}
for e in items {
effect_row(app, ui, &lp, list, &mut y, e, 0.0, &query, "effects.item", &mut apply);
effect_row(app, ui, &lp, list, &mut y, &mut nav, e, 2, 0.0, &query, "effects.item", &mut apply);
}
}
}
@@ -498,6 +779,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
let content = y + scroll.offset - list.min.y;
scroll.end(ui, &mut app.auto, "effects.scroll", content, &t);
if let Some(a) = list_keys(app, &ctx, &nav, list) {
apply = Some(a);
}
if let Some(name) = egui::DragAndDrop::payload::<DragPayload>(&ctx).and_then(|p| match p.as_ref() {
DragPayload::Effect(id) => Some(effectcraft_engine::effects::find(id).map(|e| crate::i18n::effects::name(e.id, e.name)).unwrap_or_default()),
DragPayload::Preset { name, .. } => Some(name.clone()),
+1 -1
View File
@@ -362,7 +362,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt
}
ControlType::Checkbox => {
let mut on = v.as_bool();
let r = ui.checkbox(&mut on, "");
let r = crate::widgets::labelled_checkbox(ui, &mut on, "");
if r.changed() {
out = Some(json!(on));
}
+23 -11
View File
@@ -760,7 +760,16 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
)
}
// File ▸ Export ▸ Adobe Premiere Pro Project…: Final Cut Pro XML (or FCPXML / OTIO / EDL).
"file.exportTimeline" if !has(p, &["path"]) => export_timeline_form(s),
"file.exportTimeline" if !has(p, &["path"]) => {
// Its pre-renders run as a job in Window ▸ Progress, not with the window waiting (#2269).
match base.as_object_mut() {
Some(m) => {
m.insert("background".into(), json!(true));
}
None => base = json!({"background": true}),
}
export_timeline_form(s)
}
"file.importPlaceholder" | "file.replaceWithPlaceholder" if p.as_object().is_none_or(|m| m.is_empty()) => (
"New Placeholder".into(),
vec![
@@ -888,7 +897,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 +1027,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() {
@@ -1117,8 +1126,11 @@ mod tests {
let params = app.dialog_state.form.params();
assert!(params.get("note").is_none());
assert_eq!(params["format"], "xml");
let r = app.session.execute("file.exportTimeline", params).unwrap();
assert_eq!(r["path"], path.as_str());
assert_eq!(params["background"], true, "the dialog exports in the background");
assert!(app.session.execute("file.exportTimeline", params).unwrap()["job"].is_string());
app.session.wait_jobs();
let done = app.session.job_log.last().unwrap();
assert_eq!((done.status.as_str(), &done.result["path"]), ("done", &json!(path)));
assert!(std::fs::read_to_string(&path).unwrap().contains("<xmeml"));
}
+7 -2
View File
@@ -245,7 +245,7 @@ pub fn open(app: &mut EffectcraftApp, p: &Json) -> Result<Json, String> {
/// Set the property to the draft: one `prop.set` at the time the editor opened, in composition
/// time, or nothing when the draft is unchanged (so OK adds no keyframe).
fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) {
app.dialog = None;
close(app);
let d = std::mem::take(&mut app.dialog_state.gradient);
let g = d.gradient();
// The preview already set the property to this draft, in one merged undo step: OK keeps it.
@@ -279,7 +279,12 @@ pub fn finish(app: &mut EffectcraftApp, ok: bool, ctx: &egui::Context) {
preview_merge::rollback(app, cp, d.applied_at.as_ref());
}
app.session.history.merge_key = None;
app.dialog = None;
close(app);
}
/// Close the editor: back to the Layer Style dialog when it opened from there (#2057).
fn close(app: &mut EffectcraftApp) {
app.dialog = std::mem::take(&mut app.dialog_state.layer_style.gradient_open).then_some(Dialog::LayerStyles);
}
/// Keep the property in step with the draft while Preview is on: apply a changed draft with one
+14 -7
View File
@@ -470,6 +470,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
let mut key_screens: Vec<(serde_json::Value, Pos2)> = vec![];
for c in &cs {
let (lid, uid, d) = (c.layer.id, c.prop.uid, c.dim);
let unlocked = !c.layer.switches.locked;
for k in &c.keys {
let kp = pos2(tm.x(k.t), ymap(k.v));
// In the speed graph an end key rises with the handle being dragged; skipping it once it
@@ -491,9 +492,10 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
app.auto.add(&format!("timeline.graph.handle.{uid}.{d}.{}.{side}", k.idx), hr, "Bezier handle");
// The same side's handles of every selected key move with it, by as much as the
// pointer moved (After Effects), in one undo step.
if hresp.drag_started() {
if unlocked && hresp.drag_started() {
let grabs: Vec<HandleGrab> = cs
.iter()
.filter(|c2| !c2.layer.switches.locked)
.flat_map(|c2| {
c2.keys.iter().flat_map(move |k2| {
k2.handles.iter().filter(|h2| h2.out == h.out).map(move |h2| HandleGrab {
@@ -513,7 +515,8 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
let press = ui.input(|i| i.pointer.press_origin()).unwrap_or(hp);
ctx.data_mut(|dd| dd.insert_temp::<HandleDrag>(handle_drag_id(), (press, grabs)));
}
if hresp.dragged()
if unlocked
&& hresp.dragged()
&& let Some(pt) = hresp.interact_pointer_pos()
&& let Some((press, grabs)) = ctx.data(|dd| dd.get_temp::<HandleDrag>(handle_drag_id()))
{
@@ -544,7 +547,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
app.auto.add(&format!("timeline.graph.key.{uid}.{d}.{}", k.idx), kr, &c.prop.name);
let mods = ui.input(|i| i.modifiers);
let key = &c.prop.keys[k.idx];
if kresp.clicked() && mods.command {
if unlocked && kresp.clicked() && mods.command {
// Ctrl+click: Linear ↔ Auto Bezier; Ctrl+Alt+click: Hold on / off (as in the Timeline).
actions.push(("keys.select".into(), json!({"keys": [kjson.clone()]})));
if mods.alt {
@@ -553,20 +556,22 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
let linear = key.in_interp == Interp::Linear && key.out_interp == Interp::Linear;
actions.push(("keys.interpolation".into(), json!({"interpolation": if linear { "autoBezier" } else { "linear" }})));
}
} else if kresp.clicked() && mods.shift {
} else if unlocked && kresp.clicked() && mods.shift {
actions.push(("keys.select".into(), json!({"keys": [kjson.clone()], "toggle": true})));
} else if (kresp.clicked() || kresp.drag_started()) && !k.sel {
} else if unlocked && (kresp.clicked() || kresp.drag_started() || kresp.secondary_clicked()) && !k.sel {
actions.push(("keys.select".into(), json!({"keys": [kjson.clone()], "add": mods.shift})));
}
// A drag moves every selected key, by how far the pointer went from where the button
// went down (the key doesn't jump under the pointer); Shift keeps it to the time or
// the value axis, whichever moved more.
if kresp.drag_started()
if unlocked
&& kresp.drag_started()
&& let Some(o) = ui.input(|i| i.pointer.press_origin())
{
ctx.data_mut(|dd| dd.insert_temp(key_grab_id(), KeyGrab { press: o, t: k.t }));
}
if kresp.dragged()
if unlocked
&& kresp.dragged()
&& let Some(pt) = kresp.interact_pointer_pos()
&& let Some(g) = ctx.data(|dd| dd.get_temp::<KeyGrab>(key_grab_id()))
{
@@ -587,6 +592,8 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
ctx.data_mut(|dd| dd.insert_temp(drag_id, false));
actions.push(("__endMerge".into(), json!({})));
}
// Right-click: the Timeline's key menu, with the same commands.
crate::widgets::context_menu(&kresp, |ui| super::timeline::key_menu(ui, app, c.layer, c.prop, key, k.sel, actions));
}
}
+1 -1
View File
@@ -168,7 +168,7 @@ pub fn velocity(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
});
ui.add_space(8.0);
}
let r = ui.checkbox(&mut d.continuous, tr("Continuous (Lock Outgoing to Incoming)"));
let r = crate::widgets::labelled_checkbox(ui, &mut d.continuous, tr("Continuous (Lock Outgoing to Incoming)"));
app.auto.add("dialog.velocity.continuous", r.rect, "Continuous");
if d.continuous {
d.out_speed = d.in_speed.clone();
@@ -23,6 +23,8 @@ pub struct LayerStyleState {
pub selected: String,
pub undo_mark: usize,
checkpoint: Option<Checkpoint>,
/// The Gradient Editor opened from this dialog: closing it comes back here (#2057).
pub gradient_open: bool,
}
/// The project and undo history as a dialog found them, for a Cancel that rolls its live edits back.
@@ -58,7 +60,8 @@ pub fn open(app: &mut EffectcraftApp, params: &Value) -> Result<(), String> {
return Err(format!("unknown layer style `{style}`"));
}
let checkpoint = checkpoint(app);
app.dialog_state.layer_style = LayerStyleState { layer, selected: style, undo_mark: app.session.history.undo.len(), checkpoint: Some(checkpoint) };
app.dialog_state.layer_style =
LayerStyleState { layer, selected: style, undo_mark: app.session.history.undo.len(), checkpoint: Some(checkpoint), gradient_open: false };
app.dialog = Some(Dialog::LayerStyles);
Ok(())
}
@@ -89,7 +92,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;
@@ -118,10 +121,6 @@ fn control(ui: &mut egui::Ui, p: &Property, v: &KV) -> Option<Value> {
let changed = crate::widgets::srgb_color_button(ui, &mut rgb).changed();
changed.then(|| json!(rgb))
}
(KV::Gradient(_), _) => {
ui.label(egui::RichText::new(tr("Edit in the Timeline")).weak());
None
}
_ => {
ui.label(egui::RichText::new(format!("{v:?}").chars().take(24).collect::<String>()).weak());
None
@@ -177,7 +176,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 {
@@ -228,6 +227,19 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
}
let lbl = ui.label(&p.name);
let v = p.value_at(lt);
if matches!(v, KV::Gradient(_)) {
// The Gradient Editor, over this dialog (a style the dialog
// only shows has no property to edit yet).
let path = g.match_path_of(p.uid).unwrap_or_default();
let r = ui.add_enabled(missing.is_none(), egui::Button::new(tr("Edit Gradient…")));
if r.clicked() {
actions.push(("prop.editGradient".into(), json!({"layer": lid, "prop": p.uid})));
app.dialog_state.layer_style.gradient_open = true;
}
app.auto.add(&format!("dialog.layerStyle.value.{sel}/{path}"), r.rect, &p.name);
ui.end_row();
return;
}
let edit = ui.scope(|ui| control(ui, p, &v));
let path = g.match_path_of(p.uid).unwrap_or_default();
if let Some(nv) = edit.inner {
+2 -2
View File
@@ -155,7 +155,7 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
ui.end_row();
});
ui.add_space(8.0);
let r = ui.checkbox(&mut d.cue, tr("Cue Point"));
let r = crate::widgets::labelled_checkbox(ui, &mut d.cue, tr("Cue Point"));
app.auto.add("dialog.marker.cue", r.rect, "Cue Point");
ui.add_enabled_ui(d.cue, |ui| {
ui.horizontal(|ui| {
@@ -191,7 +191,7 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
});
ui.add_space(8.0);
ui.horizontal(|ui| {
let r = ui.checkbox(&mut d.protected, tr("Protected Region"));
let r = crate::widgets::labelled_checkbox(ui, &mut d.protected, tr("Protected Region"));
app.auto.add("dialog.marker.protected", r.rect, "Protected Region");
ui.add_space(20.0);
ui.label(tr("Label:"));
+1 -1
View File
@@ -264,7 +264,7 @@ fn fmt_rate(r: f64) -> String {
fn check_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, at: egui::Pos2, key: &str, text: &str, on: bool) -> bool {
let t = app.tokens;
let r = Rect::from_min_size(pos2(at.x, at.y - 8.0), vec2(16.0, 16.0));
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::unique(("pv-check", key))).clicked();
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::unique(("pv-check", key))).changed();
p.text(pos2(r.max.x + 5.0, at.y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text);
app.auto.add(&format!("preview.{key}"), r, text);
clicked
+3 -3
View File
@@ -239,7 +239,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
.enumerate()
{
let r = Rect::from_min_size(pos2(x0 + i as f32 * (w / 2.0), y), vec2(18.0, 20.0));
if widgets::checkbox(ui, r, on, &t, egui::Id::unique(key)).clicked() {
if widgets::checkbox(ui, r, on, &t, egui::Id::unique(key)).changed() {
set(app, json!({k: !on}));
}
p.text(pos2(r.max.x + 4.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim);
@@ -273,7 +273,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
y += 26.0;
let r = Rect::from_min_size(pos2(x0, y), vec2(18.0, 20.0));
if widgets::checkbox(ui, r, o.show_overlay, &t, egui::Id::unique("paint.showOverlay")).clicked() {
if widgets::checkbox(ui, r, o.show_overlay, &t, egui::Id::unique("paint.showOverlay")).changed() {
set(app, json!({"showOverlay": !o.show_overlay}));
}
app.auto.add("paint.showOverlay", r, "Clone Source Overlay");
@@ -282,7 +282,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
set(app, json!({"overlayOpacity": nv}));
}
let dr = Rect::from_min_size(pos2(x0 + w - 18.0, y), vec2(18.0, 20.0));
if widgets::checkbox(ui, dr, o.overlay_difference, &t, egui::Id::unique("paint.overlayDifference")).clicked() {
if widgets::checkbox(ui, dr, o.overlay_difference, &t, egui::Id::unique("paint.overlayDifference")).changed() {
set(app, json!({"overlayDifference": !o.overlay_difference}));
}
app.auto.add("paint.overlayDifference", dr, "Difference mode overlay");
+1 -1
View File
@@ -23,7 +23,7 @@ pub fn dropdown(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, auto: &str
pub fn checkbox(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, text: &str, on: bool) -> bool {
let t = app.tokens;
let r = Rect::from_min_size(at, vec2(16.0, 16.0));
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::unique(("kit-cb", auto))).clicked();
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::unique(("kit-cb", auto))).changed();
ui.painter().text(pos2(r.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text);
let full = Rect::from_min_size(at, vec2(22.0 + text.len() as f32 * 6.5, 16.0));
app.auto.add(auto, full, text);
+1 -1
View File
@@ -54,7 +54,7 @@ pub fn create_nulls(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
acts.push((id, if id == "paths.tracePath" { json!({"loop": looping}) } else { json!({}) }));
}
}
let r = ui.checkbox(&mut looping, tr("Loop (Trace Path)"));
let r = crate::widgets::labelled_checkbox(&mut ui, &mut looping, tr("Loop (Trace Path)"));
app.auto.add("createNulls.loop", r.rect, "Loop");
ui.data_mut(|d| d.insert_temp(loop_id, looping));
run(app, &ctx, acts);
+2 -2
View File
@@ -141,11 +141,11 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
);
ui.add_space(4.0);
ui.add_enabled_ui(!d.leave, |ui| {
let r = ui.checkbox(&mut d.adjust, tr("Adjust composition duration to the time span of the selected layers"));
let r = crate::widgets::labelled_checkbox(ui, &mut d.adjust, tr("Adjust composition duration to the time span of the selected layers"));
app.auto.add("dialog.precompose.adjust", r.rect, "Adjust composition duration");
});
ui.add_space(8.0);
let r = ui.checkbox(&mut d.open, tr("Open New Composition"));
let r = crate::widgets::labelled_checkbox(ui, &mut d.open, tr("Open New Composition"));
app.auto.add("dialog.precompose.open", r.rect, "Open New Composition");
ui.add_space(14.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+9 -4
View File
@@ -618,9 +618,14 @@ fn item_menu(
actions.push(("project.move".into(), json!({"items": targets(app, id, selected), "folder": null})));
ui.close();
}
if !it.is_folder() && ui.button(crate::i18n::tr("Add to Composition")).clicked() {
actions.push(("layer.addItem".into(), json!({"item": id.0})));
ui.close();
// A composition cannot contain itself, so its own row offers nothing to add to (#2589).
if !it.is_folder() {
let add = ui.add_enabled(app.session.state.active_comp != Some(id), egui::Button::new(crate::i18n::tr("Add to Composition")));
app.auto.add("project.context.addToComposition", add.rect, "Add to Composition");
if add.clicked() {
actions.push(("layer.addItem".into(), json!({"item": id.0})));
ui.close();
}
}
// File ▸ New Comp from Selection on the selection (the right-clicked row joins it
// above): several items ask how in its dialog, as in After Effects (#360).
@@ -1341,7 +1346,7 @@ fn column_menu(app: &mut EffectcraftApp, resp: &egui::Response) {
ui.label(egui::RichText::new(crate::i18n::tr("Columns")).weak());
for (key, label, _) in COLUMNS {
let mut on = app.ui.project_columns.iter().any(|c| c == key);
if ui.checkbox(&mut on, crate::i18n::tr(label)).changed() {
if crate::widgets::labelled_checkbox(ui, &mut on, crate::i18n::tr(label)).changed() {
set_column(&mut app.ui.project_columns, key, on);
}
}
+2 -1
View File
@@ -264,6 +264,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
let vx = r.min.x + (r.width() * 0.5).max(130.0);
let label = Rect::from_min_max(pos2(r.min.x + 42.0, r.min.y), pos2(vx - 20.0, r.max.y));
let resp = ui.interact(label, egui::Id::unique(("props-label", uid)), Sense::click());
crate::a11y::role_name(&resp, egui::accesskit::Role::Button, &pr.name);
app.auto.add(&format!("{base}.label"), label, &pr.name);
if resp.double_clicked() {
app.ui.timeline.search.clear();
@@ -456,7 +457,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}));
}
+164 -31
View File
@@ -20,6 +20,7 @@ use effectcraft_engine::project::render_templates::RenderTemplates;
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use serde_json::{Value, json};
use crate::a11y;
use crate::theme::Tokens;
use crate::widgets::check_label as mark;
use crate::{EffectcraftApp, widgets};
@@ -64,6 +65,11 @@ pub fn utc_stamp(unix: u64) -> String {
format!("{y:04}-{}", t.format("%m-%d %H:%M:%S"))
}
/// A queue item's row: its click area, keyboard focus and accessibility node.
fn row_id(item: u64) -> egui::Id {
egui::Id::unique(("rq-row", item))
}
fn status_color(t: &Tokens, s: &RenderStatus) -> Color32 {
match s {
RenderStatus::Done => Color32::from_rgb(0x6c, 0xc0, 0x6c),
@@ -494,14 +500,19 @@ fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::P
let render_b = Rect::from_min_size(pos2(top.max.x - 12.0 - bw, top.min.y + 34.0), vec2(bw, 26.0));
let stop_b = Rect::from_min_size(pos2(render_b.min.x - 8.0 - bw, render_b.min.y), vec2(bw, 26.0));
let can_render = !rendering && queue.iter().any(RenderQueueItem::is_queued) && !available.is_empty();
if widgets::text_button(ui, render_b, crate::i18n::tr("Render"), can_render, &t, egui::Id::unique("rq-render")).clicked() && can_render {
let render_label = tr("Render");
let render_resp = widgets::text_button(ui, render_b, render_label, can_render, &t, egui::Id::unique("rq-render"));
a11y::button(&render_resp, render_label, can_render);
if render_resp.clicked() && can_render {
actions.push(("renderQueue.render", json!({"wait": false})));
}
app.auto.add("renderQueue.render", render_b, "Render");
// 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() {
let notify_resp = widgets::checkbox(ui, nb, notify, &t, egui::Id::unique("rq-notify"));
a11y::check_box(&notify_resp, tr("Notify"), notify, true);
if notify_resp.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);
@@ -534,8 +545,9 @@ fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::P
}
}
};
p.text(pos2(bar.min.x + 8.0, bar.center().y), Align2::LEFT_CENTER, bar_text, Tokens::ui(11.5), t.text);
p.text(pos2(bar.min.x + 8.0, bar.center().y), Align2::LEFT_CENTER, &bar_text, Tokens::ui(11.5), t.text);
app.auto.add("renderQueue.progress", bar, &format!("{:.0}%", frac * 100.0));
a11y::progress(&ui.interact(bar, egui::Id::unique("rq-progress"), Sense::hover()), &bar_text, frac);
}
/// The footer: the message line and the totals.
@@ -591,11 +603,16 @@ fn log_dropdown(app: &mut EffectcraftApp, ui: &mut egui::Ui, row: &ItemRow, it:
let ld = Rect::from_min_size(pos2(r.max.x - 190.0, r.min.y + 3.0), vec2(150.0, ROW_H - 6.0));
lp.text(pos2(ld.min.x - 6.0, r.center().y), Align2::RIGHT_CENTER, tr("Log:"), Tokens::ui(11.5), t.text_dim);
let lid = egui::Id::unique(("rq-log", it.id));
if widgets::dropdown(ui, ld, it.log.label(), &t, lid).clicked() && !rendering {
let log_resp = widgets::dropdown(ui, ld, it.log.label(), &t, lid);
a11y::combo_box(&log_resp, tr("Log:").trim_end_matches(':'), it.log.label(), !rendering);
if log_resp.clicked() && !rendering {
widgets::open_popup(ui, lid);
}
let llabels: Vec<String> = RenderLog::ALL.iter().map(|l| mark(it.log == *l, l.label())).collect();
if let Some(i) = widgets::popup_menu(ui, lid, ld.left_bottom(), &llabels, None) {
if widgets::by_keyboard(ui) {
log_resp.request_focus();
}
actions.push(("renderQueue.setLog", json!({"item": it.id, "log": RenderLog::ALL[i].label()})));
}
app.auto.add(&aid("log"), ld, it.log.label());
@@ -630,13 +647,23 @@ fn output_to(
lp.text(pos2(ox, r.center().y), Align2::LEFT_CENTER, tr("Output To:"), Tokens::ui(11.5), t.text_dim);
let odd = Rect::from_min_size(pos2(ox + 62.0, r.min.y + 3.0), vec2(22.0, ROW_H - 6.0));
let oid = egui::Id::unique(("rq-out", it.id));
if widgets::dropdown(ui, odd, "", &t, oid).clicked() && !rendering {
let out_resp = widgets::dropdown(ui, odd, "", &t, oid);
a11y::combo_box(
&out_resp,
tr("Output To:").trim_end_matches(':'),
&app.session.resolve_output(it).unwrap_or_else(|| "Not yet specified".into()),
!rendering,
);
if out_resp.clicked() && !rendering {
widgets::open_popup(ui, oid);
}
app.auto.add(&aid("outputTo"), odd, "Output To");
let omenu = output_to_menu(it);
let olabels: Vec<String> = omenu.iter().map(|(l, _)| l.clone()).collect();
if let Some(i) = widgets::popup_menu(ui, oid, odd.left_bottom(), &olabels, None) {
if widgets::by_keyboard(ui) {
out_resp.request_focus();
}
if omenu[i].1.get("choose").is_some() {
match pick_output(app, it) {
Some(Some(path)) => actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": path}))),
@@ -654,6 +681,7 @@ fn output_to(
let mut buf = editing.as_ref().map(|(_, b)| b.clone()).unwrap_or_default();
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(path_rect));
let resp = child.add(egui::TextEdit::singleline(&mut buf).desired_width(path_rect.width()).font(Tokens::ui(11.5)));
a11y::text_input(&resp, tr("Output file name template"), &buf);
if resp.lost_focus() {
if ui.input(|i| i.key_pressed(egui::Key::Enter)) && !buf.trim().is_empty() {
actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": buf.trim()})));
@@ -666,6 +694,7 @@ fn output_to(
} else {
let shown = app.session.resolve_output(it).unwrap_or_else(|| "Not yet specified".into());
let resp = ui.interact(path_rect, egui::Id::unique(("rq-path", it.id)), Sense::click());
a11y::button(&resp, &tr_args("Output file: {}", &[&shown]), !rendering);
let col = if resp.hovered() { t.accent_hover } else { t.hot_text };
lp.with_clip_rect(path_rect.intersect(list)).text(pos2(path_rect.min.x, r.center().y), Align2::LEFT_CENTER, &shown, Tokens::ui(11.5), col);
// Clicking the file name opens the save dialog (Output Movie To); Alt-click, or a
@@ -687,19 +716,113 @@ fn output_to(
/// Delete removes the selected item when the panel has focus (`allowed`: not editing a path and
/// not rendering).
fn delete_selected(app: &EffectcraftApp, ui: &egui::Ui, allowed: bool, selected: &mut Option<u64>, actions: &mut Vec<Action>) {
fn delete_selected(app: &EffectcraftApp, ui: &egui::Ui, allowed: bool, on_row: bool, selected: &mut Option<u64>, actions: &mut Vec<Action>) {
if app.ui.focused == crate::dock::PanelKind::RenderQueue
&& allowed
&& let Some(id) = *selected
&& app.dialog.is_none()
&& !crate::widgets::any_menu_open(ui.ctx())
&& ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace))
&& !ui.ctx().egui_wants_keyboard_input()
// (A focused queue row is not typing; any other focused widget may be.)
&& (on_row || !ui.ctx().egui_wants_keyboard_input())
{
actions.push(("renderQueue.remove", json!({"item": id})));
*selected = None;
}
}
// Keyboard: Up / Down move between the queued items, Home / End go to the first / last, Right
// and Left open and close the item's settings, Enter and Space do the same (Space only with a
// row focused: otherwise it plays the preview). The focus follows the selection.
///
/// Returns whether a queue row has keyboard focus.
fn list_keys(
app: &mut EffectcraftApp,
ctx: &egui::Context,
queue: &[RenderQueueItem],
(open, selected): (&mut Vec<u64>, &mut Option<u64>),
can_navigate: bool,
tops: &[(u64, f32)],
list_h: f32,
) -> bool {
let focused = ctx.memory(|m| m.focused());
let on_row = focused.is_some_and(|f| queue.iter().any(|i| row_id(i.id) == f));
let by_mouse = focused.is_none() && app.ui.focused == crate::dock::PanelKind::RenderQueue;
if (on_row || by_mouse) && can_navigate && app.dialog.is_none() && !widgets::any_menu_open(ctx) {
let keys: Vec<egui::Key> = ctx.input(|i| {
i.events
.iter()
.filter_map(|e| match e {
egui::Event::Key { key, pressed: true, modifiers, .. } if !modifiers.any() => Some(*key),
_ => None,
})
.collect()
});
let mut moved = None;
for key in keys {
use egui::Key;
let at = (*selected).and_then(|s| queue.iter().position(|i| i.id == s));
let Some(last) = queue.len().checked_sub(1) else { break };
let to = match key {
Key::ArrowDown => Some(at.map_or(0, |a| a.saturating_add(1).min(last))),
Key::ArrowUp => Some(at.map_or(last, |a| a.saturating_sub(1))),
Key::Home if on_row => Some(0),
Key::End if on_row => Some(last),
_ => None,
};
if let Some(item) = to.and_then(|to| queue.get(to)) {
*selected = Some(item.id);
moved = Some(item.id);
continue;
}
let Some(item) = (*selected).filter(|s| queue.iter().any(|i| i.id == *s)) else { continue };
match key {
Key::ArrowRight if !open.contains(&item) => open.push(item),
Key::ArrowLeft => open.retain(|i| *i != item),
Key::Enter | Key::Space if on_row || key == Key::Enter => {
if open.contains(&item) {
open.retain(|i| *i != item);
} else {
open.push(item);
}
}
_ => {}
}
}
if let Some(item) = moved {
if let Some((_, top)) = tops.iter().find(|(id, _)| *id == item) {
widgets::PanelScroll::reveal(ctx, egui::Id::unique("rq-scroll"), *top, *top + ROW_H, list_h);
}
app.ui.focused = crate::dock::PanelKind::RenderQueue;
if on_row {
a11y::focus_next_pass(ctx, "rq", row_id(item));
}
ctx.request_repaint();
}
// The arrows are the queue's, not egui's spatial focus moves.
if on_row {
ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None));
}
}
on_row
}
/// An item's Status cell: its state, or the percentage while it is the one rendering.
fn status_text(it: &RenderQueueItem, progress: Option<&effectcraft_engine::render_queue::JobState>, rendering: bool) -> String {
match progress.filter(|p| rendering && p.current == Some(it.id)) {
Some(pr) => format!("Rendering {:.0}%", if pr.total > 0 { pr.done as f64 * 100.0 / pr.total as f64 } else { 0.0 }),
None => it.status.label().to_string(),
}
}
/// An item's Render Time cell: the running time while it renders, else its last render time.
fn render_time_text(it: &RenderQueueItem, progress: Option<&effectcraft_engine::render_queue::JobState>, rendering: bool) -> String {
match progress.filter(|p| rendering && p.current == Some(it.id)) {
Some(p) => hms(p.item_elapsed),
None => it.render_time.map(|s| if s < 60.0 { format!("{s:.1} s") } else { hms(s) }).unwrap_or_else(|| "-".into()),
}
}
/// The Render Queue panel: the Current Render strip (elapsed and remaining time, Render/Stop and
/// Notify, plus the progress bar), the column header, the queued items with their output template,
/// status and timings, and the footer totals. Comps dragged from the Project are queued on
@@ -760,6 +883,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if queue.is_empty() {
lp.text(pos2(list.center().x, list.min.y + 30.0), Align2::CENTER_CENTER, tr("The render queue is empty."), Tokens::ui(12.0), t.text_faint);
}
let ctx = ui.ctx().clone();
// Where each item's row starts in the scrolled content, for the keys.
let mut tops: Vec<(u64, f32)> = Vec::new();
let editing_id = egui::Id::unique("rq-edit-output");
let mut editing: Option<(u64, String)> = ui.data(|d| d.get_temp(editing_id));
for (k, it) in queue.iter().enumerate() {
@@ -779,16 +905,27 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
t.row_alt
},
);
let row_resp = ui.interact(row, egui::Id::unique(("rq-row", it.id)), Sense::click());
if row_resp.clicked() {
let row_resp = ui.interact(row, row_id(it.id), Sense::click());
tops.push((it.id, row.min.y - list.min.y + scroll.offset));
if a11y::take_pending_focus(&ctx, "rq", row_resp.id) {
row_resp.request_focus();
}
if row_resp.clicked() || row_resp.gained_focus() {
selected = Some(it.id);
app.ui.focused = crate::dock::PanelKind::RenderQueue;
}
if row_resp.has_focus() {
lp.rect_stroke(row.shrink(0.5), 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside);
}
let comp_item = app.session.project.item(it.comp);
let name = comp_item.map(|i| i.name.clone()).unwrap_or_else(|| "(missing composition)".into());
crate::widgets::context_menu(&row_resp, |ui| row_menu(ui, &mut actions, it.id, k, queue.len()));
app.auto.add(&aid("row"), row, &format!("#{n}"));
let is_open = open.contains(&it.id);
let tw = Rect::from_center_size(pos2(cx_twirl + 8.0, row.center().y), vec2(14.0, 14.0));
if widgets::twirl(ui, tw, is_open, egui::Id::unique(("rq-twirl", it.id)), &t).clicked() {
let tw_resp = widgets::twirl(ui, tw, is_open, egui::Id::unique(("rq-twirl", it.id)), &t);
a11y::disclosure(&tw_resp, &tr_args(if is_open { "Hide details of {}" } else { "Show details of {}" }, &[&name]), is_open);
if tw_resp.clicked() {
if is_open {
open.retain(|i| *i != it.id);
} else {
@@ -797,37 +934,27 @@ 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 {
let cb_resp = ui.add_enabled_ui(!rendering, |ui| widgets::checkbox(ui, cb, it.render, &t, egui::Id::unique(("rq-cb", it.id)))).inner;
a11y::check_box(&cb_resp, &format!("{}: {name}", tr("Render")), it.render, !rendering);
if cb_resp.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" });
let comp_item = app.session.project.item(it.comp);
let label_col = comp_item.map(|i| t.label(i.label)).unwrap_or(t.text_faint);
lp.rect_filled(Rect::from_center_size(pos2(cx_label + 6.0, row.center().y), vec2(10.0, 10.0)), 2.0, label_col);
lp.text(pos2(cx_num + 2.0, row.center().y), Align2::LEFT_CENTER, n.to_string(), Tokens::ui(12.0), t.text_dim);
let name = comp_item.map(|i| i.name.clone()).unwrap_or_else(|| "(missing composition)".into());
lp.text(pos2(cx_name + 2.0, row.center().y), Align2::LEFT_CENTER, &name, Tokens::ui(12.0), t.text);
let status = if rendering && let Some(pr) = progress.as_ref().filter(|p| p.current == Some(it.id)) {
format!("Rendering {:.0}%", if pr.total > 0 { pr.done as f64 * 100.0 / pr.total as f64 } else { 0.0 })
} else {
it.status.label().to_string()
};
let status = status_text(it, progress.as_ref(), rendering);
let st_rect = Rect::from_min_size(pos2(cx_status, row.min.y), vec2(w_status, ROW_H));
lp.text(pos2(cx_status + 2.0, row.center().y), Align2::LEFT_CENTER, &status, Tokens::ui(12.0), status_color(&t, &it.status));
if let RenderStatus::Failed(e) = &it.status {
ui.interact(st_rect, egui::Id::unique(("rq-st", it.id)), Sense::hover()).on_hover_text(e);
}
app.auto.add(&aid("status"), st_rect, &status);
if let Some(s) = it.started {
lp.text(pos2(cx_started + 2.0, row.center().y), Align2::LEFT_CENTER, utc_stamp(s), Tokens::ui(11.5), t.text_dim);
} else {
lp.text(pos2(cx_started + 2.0, row.center().y), Align2::LEFT_CENTER, "-", Tokens::ui(11.5), t.text_faint);
}
let rt = if progress.as_ref().and_then(|p| p.current) == Some(it.id) && rendering {
progress.as_ref().map(|p| hms(p.item_elapsed)).unwrap_or_default()
} else {
it.render_time.map(|s| if s < 60.0 { format!("{s:.1} s") } else { hms(s) }).unwrap_or_else(|| "-".into())
};
a11y::tree_item(&row_resp, &format!("{n}. {name}, {status}"), is_sel, 1, Some(is_open));
let (started, started_col) = it.started.map_or(("-".to_string(), t.text_faint), |s| (utc_stamp(s), t.text_dim));
lp.text(pos2(cx_started + 2.0, row.center().y), Align2::LEFT_CENTER, started, Tokens::ui(11.5), started_col);
let rt = render_time_text(it, progress.as_ref(), rendering);
lp.text(pos2(cx_time + 2.0, row.center().y), Align2::LEFT_CENTER, rt, Tokens::ui(11.5), t.text_dim);
if !is_open {
@@ -849,11 +976,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let dd = Rect::from_min_size(pos2(ix + 70.0, r.min.y + 3.0), vec2(220.0, ROW_H - 6.0));
let did = egui::Id::unique(("rq-dd", it.id, sub));
let shown: String = if summary.chars().count() > 31 { summary.chars().take(30).chain(std::iter::once('…')).collect() } else { summary.clone() };
if widgets::dropdown(ui, dd, &shown, &t, did).clicked() && !rendering {
let dd_resp = widgets::dropdown(ui, dd, &shown, &t, did);
a11y::combo_box(&dd_resp, label.trim_end_matches(':'), &summary, !rendering);
if dd_resp.clicked() && !rendering {
widgets::open_popup(ui, did);
}
let labels: Vec<String> = menu.iter().map(|(l, _)| l.clone()).collect();
if let Some(i) = widgets::popup_menu(ui, did, dd.left_bottom(), &labels, None) {
if widgets::by_keyboard(ui) {
dd_resp.request_focus();
}
let mut params = menu[i].1.clone();
if let Some(f) = params.get("form").and_then(Value::as_str) {
forms.push((it.clone(), f.to_string()));
@@ -893,14 +1025,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
None => d.remove::<(u64, String)>(editing_id),
});
delete_selected(app, ui, editing.is_none() && !rendering, &mut selected, &mut actions);
let on_row = list_keys(app, &ctx, &queue, (&mut open, &mut selected), editing.is_none(), &tops, list.height());
delete_selected(app, ui, editing.is_none() && !rendering, on_row, &mut selected, &mut actions);
// ---------------------------------------------------------- footer
footer(&p, rect, &rq, &t);
open.retain(|id| queue.iter().any(|i| i.id == *id));
ui.data_mut(|d| d.insert_temp(state_id, (open, selected)));
let ctx = ui.ctx().clone();
for (it, f) in forms {
open_menu_form(app, &it, &f);
}
@@ -11,6 +11,7 @@ use effectcraft_engine::project::render_templates::{RenderTemplates, TemplateKin
use egui::{Color32, vec2};
use serde_json::{Value, json};
use crate::a11y;
use crate::theme::Tokens;
use crate::{Dialog, EffectcraftApp};
@@ -65,6 +66,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
ui.selectable_value(&mut pick, n.clone(), n);
}
});
a11y::combo_box(&r.response, slot.label(), &cur, true);
app.auto.add(&format!("templates.default.{slot:?}"), r.response.rect, &cur);
if pick != cur {
actions.push(("renderQueue.setTemplateDefault", json!({"kind": kind, "slot": format!("{slot:?}"), "name": pick})));
@@ -82,6 +84,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
ui.selectable_value(&mut st.selected, n.clone(), n);
}
});
a11y::combo_box(&r.response, tr("Settings Name:").trim_end_matches(':'), &st.selected, true);
app.auto.add("templates.select", r.response.rect, &st.selected);
});
let builtin = RenderTemplates::is_builtin(k, &st.selected)
@@ -137,6 +140,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
ui.horizontal(|ui| {
ui.label(tr("New from Render Queue item:"));
let r = ui.add(egui::TextEdit::singleline(&mut st.new_name).hint_text(tr("template name")).desired_width(180.0));
a11y::text_input(&r, tr("template name"), &st.new_name);
app.auto.add("templates.newName", r.rect, &st.new_name);
let item = queue.last().map(|i| i.id);
let ok = item.is_some() && !st.new_name.trim().is_empty();
+68 -3
View File
@@ -13,6 +13,7 @@ use effectcraft_engine::segment::Task;
use egui::{Color32, RichText, vec2};
use serde_json::{Value, json};
use crate::a11y;
use crate::theme::{ThemeKind, Tokens};
use crate::{Dialog, EffectcraftApp};
@@ -78,6 +79,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
ui.horizontal(|ui| {
ui.add_sized(vec2(label_w, 18.0), egui::Label::new(tr("Default Output:")));
let shown = if sel.is_empty() { tr("System Default").to_string() } else { sel.clone() };
let shown_name = shown.clone();
let r = craft_ui::ComboBox::from_id_salt(("settings", key)).selected_text(shown).width(260.0).show_ui(ui, |ui| {
if ui.selectable_label(sel.is_empty(), tr("System Default")).clicked() {
acts.push(Act::Set(key.into(), json!("")));
@@ -88,6 +90,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
}
}
});
a11y::combo_box(&r.response, tr("Default Output:").trim_end_matches(':'), &shown_name, true);
reg(app, &format!("settings.{key}"), &r.response, "Default Output");
});
if devices.is_empty() {
@@ -103,10 +106,12 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
let id = format!("settings.{key}");
let tip = (!live).then(|| tr("Stored, but not used by EffectCraft yet"));
let mut resp: Option<egui::Response> = None;
// What the control is set to, for assistive technology.
let mut value_text = String::new();
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)));
}
@@ -162,6 +167,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
Kind::Choice(opts) => {
let curv = v.as_str().map_or_else(|| v.to_string(), str::to_string);
let shown = opts.iter().find(|o| o.1 == curv).map(|o| o.0).unwrap_or(curv.as_str()).to_string();
value_text = tr(&shown).to_string();
craft_ui::ComboBox::from_id_salt(("settings", key))
.selected_text(tr(&shown))
.width(220.0)
@@ -177,6 +183,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
Kind::Text | Kind::Path => {
let mut s = v.as_str().unwrap_or("").to_string();
let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(if kind == Kind::Path { 230.0 } else { 260.0 }));
value_text = s.clone();
if r.changed() {
acts.push(Act::Set(key.into(), json!(s)));
}
@@ -194,6 +201,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
let mut f = c.map(|v| v as f32 / 255.0);
let r = crate::widgets::srgb_color_button(ui, &mut f);
let rgb = if r.changed() { srgb_bytes(f) } else { c };
value_text = hex(rgb);
ui.label(RichText::new(hex(rgb)).monospace().color(t.text_dim));
if rgb != c {
acts.push(Act::Set(key.into(), json!(hex(rgb))));
@@ -207,6 +215,17 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
});
}
}
if let Some(r) = &resp {
// The field's label is drawn beside it, not on it: give the control its name.
let name = tr(label);
match kind {
Kind::Bool => {}
Kind::Choice(_) => a11y::combo_box(r, name, &value_text, true),
Kind::Text | Kind::Path => a11y::text_input(r, name, &value_text),
Kind::Color => a11y::name_value(r, name, &value_text),
_ => a11y::name(r, name),
}
}
if let Some(r) = resp {
let r = match tip {
Some(tip) => r.on_hover_text(tip),
@@ -395,6 +414,7 @@ fn appearance_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, t: &T
}
})
.response;
a11y::combo_box(&r, tr("Appearance Mode"), tr(shown), true);
reg(app, "settings.appearance.appearanceMode", &r, "Appearance Mode");
});
ui.add_space(6.0);
@@ -618,12 +638,14 @@ fn labels_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, acts: &mu
let mut f = c.map(|v| v as f32 / 255.0);
let r = crate::widgets::srgb_color_button(ui, &mut f);
let rgb = if r.changed() { srgb_bytes(f) } else { c };
a11y::name_value(&r, &tr_args("Label {} color", &[&(i + 1)]), &hex(rgb));
app.auto.add(&format!("settings.labels.{i}.color"), r.rect, "label colour");
if rgb != c {
acts.push(Act::Set(format!("labels.{i}.color"), json!(hex(rgb))));
}
let mut name = l["name"].as_str().unwrap_or("").to_string();
let r = ui.add_sized(vec2(160.0, 20.0), egui::TextEdit::singleline(&mut name));
a11y::text_input(&r, &tr_args("Label {} name", &[&(i + 1)]), &name);
app.auto.add(&format!("settings.labels.{i}.name"), r.rect, "label name");
if r.changed() {
acts.push(Act::Set(format!("labels.{i}.name"), json!(name)));
@@ -689,11 +711,21 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
ui.vertical(|ui| {
ui.set_width(page_width + 4.0);
egui::ScrollArea::vertical().id_salt("settings-pages").max_height(body_h).auto_shrink([false, false]).show(ui, |ui| {
// One Tab stop for the list (the open page); Up / Down / Home / End move
// between the pages and the focus goes with them.
// The rows touch, so the pointer never falls between two of them.
ui.spacing_mut().item_spacing.y = 0.0;
for p in &all {
let (r, resp) = ui.allocate_exact_size(vec2(page_width, 24.0), egui::Sense::click());
let ctx = ui.ctx().clone();
let page_item = |id: &str| egui::Id::unique(("settings-page", id.to_string()));
let on_list = all.iter().any(|q| ctx.memory(|m| m.has_focus(page_item(q.id))));
for (n, p) in all.iter().enumerate() {
let sel = page == p.id;
let r = ui.allocate_space(vec2(page_width, 24.0)).1;
let resp = ui.interact(r, page_item(p.id), if sel { egui::Sense::click() } else { egui::Sense::CLICK });
a11y::list_item(&resp, crate::i18n::settings_page_title(p.title), sel, n + 1, all.len());
if a11y::take_pending_focus(&ctx, "settings", resp.id) {
resp.request_focus();
}
if sel {
ui.painter().rect_filled(r, 3.0, t.accent);
} else if resp.hovered() {
@@ -708,11 +740,44 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
(page_width - 20.0).max(0.0),
if sel { Color32::WHITE } else { t.text },
);
if resp.has_focus() {
ui.painter().rect_stroke(r.shrink(1.0), 3.0, egui::Stroke::new(2.0, t.text), egui::StrokeKind::Inside);
}
app.auto.add(&format!("settings.page.{}", p.id), r, p.title);
if resp.on_hover_text(crate::i18n::settings_page_title(p.title)).clicked() {
page = p.id.to_string();
}
}
if on_list {
let at = all.iter().position(|q| q.id == page).unwrap_or(0);
let last = all.len().saturating_sub(1);
let mut to = at;
for key in ctx.input(|i| {
i.events
.iter()
.filter_map(|e| match e {
egui::Event::Key { key, pressed: true, modifiers, .. } if !modifiers.any() => Some(*key),
_ => None,
})
.collect::<Vec<_>>()
}) {
to = match key {
egui::Key::ArrowDown => to.saturating_add(1).min(last),
egui::Key::ArrowUp => to.saturating_sub(1),
egui::Key::Home => 0,
egui::Key::End => last,
_ => to,
};
}
if to != at
&& let Some(q) = all.get(to)
{
page = q.id.to_string();
a11y::focus_next_pass(&ctx, "settings", page_item(q.id));
}
// The arrows are the list's, not egui's spatial focus moves.
ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None));
}
});
});
ui.separator();
+44 -21
View File
@@ -127,16 +127,21 @@ fn numeric_fields(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
let row = i / 2;
let fx = x0 + col as f32 * (w / 2.0);
let fy = y + row as f32 * 28.0;
p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
let (r, nv, _) = widgets::hot_number_at(
ui,
pos2(fx + 26.0, fy),
egui::Id::unique(("char", key)),
v,
&widgets::HotSpec { speed: 0.5, range, decimals: 0, suffix },
&t,
);
let stroke = key == "strokeWidth";
if stroke {
// The stroke-width icon, as in After Effects, not a letter that reads as "width".
crate::icons::paint(p, Rect::from_min_size(pos2(fx, fy + 1.0), vec2(18.0, 18.0)), crate::icons::Icon::StrokeWidth, t.text_dim);
} else {
p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
}
// Fractional stroke widths (the 0.25 / 0.5 / 0.75 presets) show their decimals.
let decimals = if stroke && v.fract().abs() > 1e-9 { 2 } else { 0 };
let (r, nv, _) =
widgets::hot_number_at(ui, pos2(fx + 26.0, fy), egui::Id::unique(("char", key)), v, &widgets::HotSpec { speed: 0.5, range, decimals, suffix }, &t);
app.auto.add(&format!("character.{key}"), r, key);
if stroke {
stroke_width_presets(app, ui, r, enabled, actions);
}
if let Some(nv) = nv
&& enabled
{
@@ -145,6 +150,26 @@ fn numeric_fields(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
}
}
/// The stroke width's presets menu beside its value (`r`), as in After Effects.
fn stroke_width_presets(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, enabled: bool, actions: &mut Vec<serde_json::Value>) {
const PRESETS: [f64; 22] = [0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0];
let t = app.tokens;
let br = Rect::from_min_size(pos2(r.max.x + 4.0, r.min.y + 2.0), vec2(16.0, 16.0));
let pop = egui::Id::unique("char-stroke-width-pop");
if widgets::icon_button(ui, br, crate::icons::Icon::ChevronDown, false, &t, egui::Id::unique("char-stroke-width-presets"))
.on_hover_text(tr("Stroke width presets"))
.clicked()
&& enabled
{
widgets::open_popup(ui, pop);
}
app.auto.add("character.strokeWidth.presets", br, "Stroke width presets");
let labels: Vec<String> = PRESETS.iter().map(|v| format!("{v} px")).collect();
if let Some(v) = widgets::popup_menu(ui, pop, br.left_bottom(), &labels, None).and_then(|i| PRESETS.get(i)) {
actions.push(json!({"strokeWidth": v}));
}
}
/// Kerning: Metrics / Optical / a manual value (grid row 1, left).
fn kerning_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, g: Grid, doc: &TextDoc, actions: &mut Vec<serde_json::Value>) {
let t = app.tokens;
@@ -297,7 +322,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
for (i, (key, label, on, c)) in [("fill", "Fill", doc.apply_fill, doc.fill), ("stroke", "Stroke", doc.apply_stroke, doc.stroke)].into_iter().enumerate() {
let fx = x0 + i as f32 * (w / 2.0);
let cr = Rect::from_min_size(pos2(fx, y + 3.0), vec2(14.0, 14.0));
if widgets::checkbox(ui, cr, on, &t, egui::Id::unique(("char-apply", key))).clicked() && enabled {
if ui.add_enabled_ui(enabled, |ui| widgets::checkbox(ui, cr, on, &t, egui::Id::unique(("char-apply", key)))).inner.changed() && enabled {
actions.push(json!({if key == "fill" { "applyFill" } else { "applyStroke" }: !on}));
}
app.auto.add(&format!("character.{key}.enabled"), cr, label);
@@ -350,7 +375,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
y += 32.0;
// Ligatures.
let lr = Rect::from_min_size(pos2(x0, y), vec2(14.0, 14.0));
if widgets::checkbox(ui, lr, doc.ligatures, &t, egui::Id::unique("char-ligatures")).clicked() && enabled {
if ui.add_enabled_ui(enabled, |ui| widgets::checkbox(ui, lr, doc.ligatures, &t, egui::Id::unique("char-ligatures"))).inner.changed() && enabled {
actions.push(json!({"ligatures": !doc.ligatures}));
}
app.auto.add("character.ligatures", lr, "Ligatures");
@@ -374,7 +399,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
.enumerate()
{
let cr = Rect::from_min_size(pos2(x0 + i as f32 * 120.0, y), vec2(14.0, 14.0));
if widgets::checkbox(ui, cr, on, &t, egui::Id::unique(("char-vert", key))).clicked() && enabled {
if ui.add_enabled_ui(enabled, |ui| widgets::checkbox(ui, cr, on, &t, egui::Id::unique(("char-vert", key)))).inner.changed() && enabled {
actions.push(json!({key: !on}));
}
app.auto.add(&format!("character.{key}"), cr, label);
@@ -677,7 +702,7 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
y += 30.0;
let hr = Rect::from_min_size(pos2(x0, y), vec2(14.0, 14.0));
if widgets::checkbox(ui, hr, doc.hanging_punctuation, &t, egui::Id::unique("para-hanging")).clicked() && enabled {
if ui.add_enabled_ui(enabled, |ui| widgets::checkbox(ui, hr, doc.hanging_punctuation, &t, egui::Id::unique("para-hanging"))).inner.changed() && enabled {
actions.push(json!({"hangingPunctuation": !doc.hanging_punctuation}));
}
app.auto.add("paragraph.hangingPunctuation", hr, "Roman Hanging Punctuation");
@@ -777,11 +802,7 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui:
ui.set_width(rect.width().max(180.0));
// Filter row: a checkbox that keeps only the variable fonts.
let filter_h = 22.0;
let row = ui.add_sized([ui.available_width(), filter_h], egui::Button::selectable(false, ""));
let box_rect = Rect::from_min_size(row.rect.min, vec2(18.0, filter_h));
let label_rect = Rect::from_min_max(pos2(box_rect.max.x + 2.0, row.rect.min.y), row.rect.max);
let boxed = widgets::checkbox(ui, box_rect, only_variable, &t, id.with("variable-check"));
ui.painter().text(label_rect.left_center(), Align2::LEFT_CENTER, tr("Variable fonts only"), Tokens::ui(11.5), t.text);
let row = ui.add_sized([ui.available_width(), filter_h], widgets::DragCheckbox::new(&mut only_variable, tr("Variable fonts only")));
app_ref.auto.add(&format!("{auto}.variable"), row.rect, "Variable fonts only");
// #1089: fonts installed after launch appear without restarting. The button is its own
// row: the popup is only 180 px wide, so a control pinned to the filter row covers
@@ -792,11 +813,13 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui:
app_ref.ui.status = format!("{}…", tr("Rescan Fonts"));
}
app_ref.auto.add(&format!("{auto}.rescan"), resc, "Rescan Fonts");
let toggled = row.clicked() || boxed.clicked();
let toggled = row.changed();
if toggled {
only_variable = !only_variable;
ui.data_mut(|d| d.insert_temp(variable_id, only_variable));
// Typing goes on into the search field.
}
// The native checkbox can take focus on release after changing on press.
// Typing goes on into the search field after either event.
if toggled || row.clicked() {
ui.memory_mut(|m| m.request_focus(search_id));
}
let query = state.query.trim().to_lowercase();
+110 -70
View File
@@ -1778,7 +1778,7 @@ fn empty_timeline(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if let Some(crate::panels::DragPayload::Item(dragged)) = egui::DragAndDrop::payload::<crate::panels::DragPayload>(&ctx).as_deref()
&& ui.rect_contains_pointer(rect)
{
let items: Vec<ItemId> = crate::panels::project_drop_items(app, *dragged)
let items: Vec<ItemId> = crate::panels::project_drop_items(app, *dragged, None)
.into_iter()
.map(ItemId)
.filter(|id| {
@@ -3058,7 +3058,7 @@ fn layer_mode_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc:
}
let tr = Rect::from_center_size(pos2(cw.mode + 76.0 + 6.0, cy), vec2(14.0, 14.0));
app.auto.add(&format!("timeline.layer.{}.preserveTransparency", layer.id.0), tr, "Preserve Transparency");
if widgets::checkbox(ui, tr, layer.preserve_transparency, &t, egui::Id::unique(("pt", layer.id.0))).clicked() {
if widgets::checkbox(ui, tr, layer.preserve_transparency, &t, egui::Id::unique(("pt", layer.id.0))).changed() {
// The click gives the selection the state the clicked row toggles to,
// whatever the first selected layer's value (as the switches do).
actions.push((
@@ -3565,7 +3565,7 @@ fn group_mask_mode(ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
actions.push(("layer.setMask".into(), json!({"layer": layer.id.0, "mask": uid, "mode": opts[i]})));
}
let ir = Rect::from_min_size(pos2(mr.max.x + 8.0, cy - 8.0), vec2(16.0, 16.0));
if widgets::checkbox(ui, ir, inverted, &t, egui::Id::unique(("minv", uid))).clicked() {
if widgets::checkbox(ui, ir, inverted, &t, egui::Id::unique(("minv", uid))).changed() {
actions.push(("layer.setMask".into(), json!({"layer": layer.id.0, "mask": uid, "inverted": !inverted})));
}
lp.text(pos2(ir.max.x + 4.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Inverted"), Tokens::ui(11.0), t.text_dim);
@@ -4100,72 +4100,91 @@ fn key_double_click(g: &mut Rw, rc: &RowCtx<'_>, prop: &Property, kh: &KeyHit<'_
/// The keyframe right-click menu.
fn key_context_menu(app: &mut EffectcraftApp, g: &mut Rw, rc: &RowCtx<'_>, prop: &Property, kh: &KeyHit<'_>) {
let KeyHit { k, ks, ref kresp, .. } = *kh;
let t = g.tl.t;
let actions = &mut g.tl.actions;
let layer = rc.layer;
let uid = &prop.uid;
crate::widgets::context_menu(kresp, |ui| key_menu(ui, app, layer, prop, k, ks, actions));
}
crate::widgets::context_menu(kresp, |ui| {
for (lbl, cmd, params) in [
("Edit Value…", "keys.set", json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()})),
("-", "", json!({})),
("Copy", "keys.copy", json!({})),
("Paste", "keys.paste", json!({})),
("-", "", json!({})),
("Select Equal Keyframes", "keys.selectEqual", json!({})),
("Select Previous Keyframes", "keys.selectPrevious", json!({})),
("Select Following Keyframes", "keys.selectFollowing", json!({})),
("-", "", json!({})),
("Keyframe Interpolation…", "keys.interpolation", json!({})),
("Keyframe Velocity…", "keys.velocity", json!({})),
("Toggle Hold Keyframe", "keys.toggleHold", json!({})),
(if k.muted { "Unmute Keyframes" } else { "Mute Keyframes" }, "keys.setMuted", json!({"muted": !k.muted})),
("Rove Across Time", "keys.interpolation", json!({"roving": !k.roving})),
("-", "", json!({})),
("Easy Ease", "keys.easyEase", json!({})),
("Easy Ease In", "keys.easyEaseIn", json!({})),
("Easy Ease Out", "keys.easyEaseOut", json!({})),
("Time-Reverse Keyframes", "keys.timeReverse", json!({})),
("-", "", json!({})),
("Linear", "keys.interpolation", json!({"interpolation": "linear"})),
("Bezier", "keys.interpolation", json!({"interpolation": "bezier"})),
("Auto Bezier", "keys.interpolation", json!({"interpolation": "autoBezier"})),
("Select All Keyframes", "keys.selectAll", json!({})),
("Delete", "keys.delete", json!({})),
] {
if lbl == "Select All Keyframes" {
// Keyframe colour labels.
/// The commands of a keyframe's right-click menu, for the Timeline and the Graph Editor alike. They act
/// on the selected keys, so a menu opened on an unselected key selects it first. A locked layer's keys
/// can still be copied and selected, but not edited.
pub(crate) fn key_menu(
ui: &mut egui::Ui,
app: &EffectcraftApp,
layer: &Layer,
prop: &Property,
k: &effectcraft_engine::keyframe::Keyframe,
selected: bool,
actions: &mut Vec<(String, serde_json::Value)>,
) {
let t = app.tokens;
let locked = layer.switches.locked;
let uid = &prop.uid;
let this_key = json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()});
for (lbl, cmd, params) in [
("Edit Value…", "keys.set", this_key.clone()),
("-", "", json!({})),
("Copy", "keys.copy", json!({})),
("Paste", "keys.paste", json!({})),
("-", "", json!({})),
("Select Equal Keyframes", "keys.selectEqual", json!({})),
("Select Previous Keyframes", "keys.selectPrevious", json!({})),
("Select Following Keyframes", "keys.selectFollowing", json!({})),
("-", "", json!({})),
("Keyframe Interpolation…", "keys.interpolation", json!({})),
("Keyframe Velocity…", "keys.velocity", json!({})),
("Toggle Hold Keyframe", "keys.toggleHold", json!({})),
(if k.muted { "Unmute Keyframes" } else { "Mute Keyframes" }, "keys.setMuted", json!({"muted": !k.muted})),
("Rove Across Time", "keys.interpolation", json!({"roving": !k.roving})),
("-", "", json!({})),
("Easy Ease", "keys.easyEase", json!({})),
("Easy Ease In", "keys.easyEaseIn", json!({})),
("Easy Ease Out", "keys.easyEaseOut", json!({})),
("Time-Reverse Keyframes", "keys.timeReverse", json!({})),
("-", "", json!({})),
("Linear", "keys.interpolation", json!({"interpolation": "linear"})),
("Bezier", "keys.interpolation", json!({"interpolation": "bezier"})),
("Auto Bezier", "keys.interpolation", json!({"interpolation": "autoBezier"})),
("Select All Keyframes", "keys.selectAll", json!({})),
("Delete", "keys.delete", json!({})),
] {
if lbl == "Select All Keyframes" {
// Keyframe colour labels.
ui.add_enabled_ui(!locked, |ui| {
ui.menu_button(crate::i18n::tr("Label"), |ui| {
for (i, l) in effectcraft_engine::color::Label::ALL.into_iter().enumerate() {
if widgets::label_entry(ui, l, &app.session.prefs.label_name(l), k.label as usize == i, &t).clicked() {
if !ks {
actions.push(("keys.select".into(), json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]})));
if !selected {
actions.push(("keys.select".into(), json!({"keys": [this_key.clone()]})));
}
actions.push(("keys.setLabel".into(), json!({"label": i})));
ui.close();
}
}
});
}
if lbl == "-" {
ui.separator();
continue;
}
if lbl == "Rove Across Time" && !prop.spatial {
continue;
}
if lbl == "Edit Value…" && !matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
continue;
}
if ui.button(lbl).clicked() {
if !ks {
actions.push(("keys.select".into(), json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]})));
}
actions.push((cmd.into(), params));
ui.close();
}
});
}
});
if lbl == "-" {
ui.separator();
continue;
}
if lbl == "Rove Across Time" && !prop.spatial {
continue;
}
if lbl == "Edit Value…" && !matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
continue;
}
let edits = !matches!(cmd, "keys.copy" | "keys.selectEqual" | "keys.selectPrevious" | "keys.selectFollowing" | "keys.selectAll");
// An unselected key is selected before the command runs, and locked keys cannot be selected.
let unselectable = locked && !selected && cmd != "keys.selectAll";
if ui.add_enabled(!(unselectable || locked && edits), egui::Button::new(crate::i18n::context_item(cmd, &params, lbl))).clicked() {
if !selected {
actions.push(("keys.select".into(), json!({"keys": [this_key.clone()]})));
}
actions.push((cmd.into(), params));
ui.close();
}
}
}
/// Starts a key drag (or Alt-scale).
@@ -4945,7 +4964,7 @@ fn drop_insert_layers(app: &mut EffectcraftApp, g: &mut Rw, payload: &std::sync:
match payload.as_ref() {
crate::panels::DragPayload::Item(id) => {
// Insert each selected item at the drop point, in selection order.
for (offset, item) in crate::panels::project_drop_items(app, *id).into_iter().enumerate() {
for (offset, item) in crate::panels::project_drop_items(app, *id, Some(g.tl.cid)).into_iter().enumerate() {
actions.push(("layer.addItem".into(), json!({"item": item, "index": index.saturating_add(offset), "time": time.clone()})));
}
}
@@ -5581,11 +5600,21 @@ impl<'a> ValueEdit<'a> {
Self { p, layer, prop, at, uid, merge: format!("scrub-{uid}"), scrub, proportional }
}
/// Queues the `prop.set` of one edit.
/// Queues the `prop.set` of one edit; consecutive edits of the property (a scrub) are one undo step.
fn set(&self, actions: &mut Vec<(String, serde_json::Value)>, v: serde_json::Value) {
self.push(actions, v, Some(&self.merge));
}
/// Queues the `prop.set` of a discrete choice (a drop-down pick, a check box): its own undo
/// step, never merged with the property's previous edit (#2152).
fn set_step(&self, actions: &mut Vec<(String, serde_json::Value)>, v: serde_json::Value) {
self.push(actions, v, None);
}
fn push(&self, actions: &mut Vec<(String, serde_json::Value)>, v: serde_json::Value, merge: Option<&str>) {
actions.push((
"prop.set".into(),
json!({"layer": self.layer.id.0, "prop": self.uid, "value": v, "merge": self.merge, "selected": true, "offset": self.scrub, "proportional": self.proportional}),
json!({"layer": self.layer.id.0, "prop": self.uid, "value": v, "merge": merge, "selected": true, "offset": self.scrub, "proportional": self.proportional}),
))
}
}
@@ -5664,18 +5693,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 +5728,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 +5737,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,8 +5794,8 @@ 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() {
ve.set(actions, json!(!b));
if widgets::checkbox(ui, r, *b, &t, egui::Id::unique(("cb", uid))).changed() {
ve.set_step(actions, json!(!b));
}
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
}
@@ -5775,7 +5815,7 @@ fn enum_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>,
}
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
if let Some(ni) = widgets::popup_menu(ui, pop, r.left_bottom(), &options, Some(*i as usize)) {
ve.set(actions, json!(ni));
ve.set_step(actions, json!(ni));
}
} else {
p.text(pos2(x, at.y), Align2::LEFT_CENTER, fmt_num(*i as f64, 0), Tokens::ui(12.0), t.for_value(prop.has_expression()).hot_text);
@@ -5795,7 +5835,7 @@ fn layer_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>,
if let Some(comp) = app.session.active_comp_arc()
&& let Some(chosen) = widgets::layer_picker(ui, r, &comp.layers, *l, &t, egui::Id::unique(("tl-lay", uid)))
{
ve.set(actions, chosen.map(|id| json!(id)).unwrap_or(serde_json::Value::Null));
ve.set_step(actions, chosen.map(|id| json!(id)).unwrap_or(serde_json::Value::Null));
}
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
}
@@ -540,6 +540,94 @@ fn disk_bar_range_covers_partial_frames() {
let above_limit = TMap { start: 100_001.0, ..tm };
assert_eq!(disk_bar_range(&above_limit, clip, 1.0, i64::MAX), 100_000..100_000);
}
/// The keyframe menu is one for the Timeline and the Graph Editor: its commands are drawn in the
/// interface language, and a locked layer's keys can be copied and selected but not edited.
#[test]
fn the_key_menu_is_translated_and_cannot_edit_a_locked_layers_keys() {
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
let mut s = effectcraft_engine::Session::default();
s.execute("comp.new", json!({"name": "C", "width": 64, "height": 64, "duration": 2})).unwrap();
let id = s.execute("layer.newSolid", json!({"name": "Box", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
for (t, v) in [(0.0, 0), (1.0, 50)] {
s.execute("prop.addKey", json!({"layer": id, "path": "transform/opacity", "time": t, "value": v})).unwrap();
}
let app = EffectcraftApp::new(s);
let layer = app.session.active_comp().unwrap().layer(LayerId(id)).unwrap().clone();
let prop = layer.props.prop("transform/opacity").unwrap().clone();
let key = prop.keys[0].clone();
let enabled = |locked: bool, selected: bool, label: &str| {
let mut layer = layer.clone();
layer.switches.locked = locked;
let mut actions = vec![];
let mut h = Harness::new_ui(|ui| key_menu(ui, &app, &layer, &prop, &key, selected, &mut actions));
h.run();
!h.get_by_label(label).accesskit_node().is_disabled()
};
for label in [
"Copy",
"Select Equal Keyframes",
"Select All Keyframes",
"Delete",
"Edit Value…",
"Easy Ease",
"Time-Reverse Keyframes",
"Keyframe Velocity…",
"Mute Keyframes",
] {
assert!(enabled(false, true, label), "{label} on an unlocked layer");
}
for label in ["Copy", "Select Equal Keyframes", "Select All Keyframes"] {
assert!(enabled(true, true, label), "{label} is still available on a locked layer");
}
for label in ["Delete", "Edit Value…", "Easy Ease", "Time-Reverse Keyframes", "Keyframe Velocity…", "Mute Keyframes", "Linear"] {
assert!(!enabled(true, true, label), "{label} is off on a locked layer");
assert!(!enabled(true, false, label), "{label} is off on an unselected locked key");
}
for label in ["Copy", "Select Equal Keyframes", "Select Previous Keyframes", "Select Following Keyframes"] {
assert!(!enabled(true, false, label), "{label} requires selecting the clicked key");
}
assert!(enabled(true, false, "Select All Keyframes"));
// The labels follow the interface language.
crate::i18n::set_current("es");
let copy = crate::i18n::context_item("keys.copy", &json!({}), "Copy");
assert_ne!(copy, "Copy", "the catalog translates Copy");
assert!(enabled(false, true, &copy));
crate::i18n::set_current("en");
}
#[test]
fn a_key_menu_edit_selects_the_clicked_key_before_applying_the_command() {
use egui_kittest::{Harness, kittest::Queryable};
let mut s = effectcraft_engine::Session::default();
s.execute("comp.new", json!({"width": 64, "height": 64, "duration": 2})).unwrap();
let id = s.execute("layer.newSolid", json!({"color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
for (time, value) in [(0.0, 0), (1.0, 50)] {
s.execute("prop.addKey", json!({"layer": id, "path": "transform/opacity", "time": time, "value": value})).unwrap();
}
let layer = s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone();
let prop = layer.props.prop("transform/opacity").unwrap().clone();
s.execute("keys.select", json!({"keys": [{"layer": id, "prop": prop.uid, "time": 1.0}]})).unwrap();
let mut app = EffectcraftApp::new(s);
let mut actions = vec![];
{
let mut h = Harness::new_ui(|ui| key_menu(ui, &app, &layer, &prop, &prop.keys[0], false, &mut actions));
h.run();
h.get_by_label("Easy Ease").click();
h.run();
}
assert_eq!(actions.iter().map(|(command, _)| command.as_str()).collect::<Vec<_>>(), ["keys.select", "keys.easyEase"]);
for (command, params) in actions {
app.session.execute(&command, params).unwrap();
}
let keys = &app.session.active_comp().unwrap().layer(LayerId(id)).unwrap().props.find(prop.uid).unwrap().keys;
assert_ne!(keys[0], prop.keys[0], "the clicked key was eased");
assert_eq!(keys[1], prop.keys[1], "the previously selected key was not edited");
assert_eq!(app.session.state.selected_keys.len(), 1);
assert_eq!(app.session.state.selected_keys[0].time, prop.keys[0].time);
}
#[test]
fn layer_drop_target_skips_moving_layers_beyond_the_viewport() {
let mut rows: Vec<Row> = (1..=6).map(|id| Row { layer: LayerId(id), depth: 0, kind: RowKind::Layer }).collect();
+7 -7
View File
@@ -257,10 +257,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
("scale", "Scale", settings.as_ref().is_some_and(|s| s.scale)),
] {
let cr = Rect::from_min_size(pos2(xx, y + 2.0), vec2(16.0, 16.0));
let resp = widgets::checkbox(ui, cr, on && transformish, &t, egui::Id::unique(("tracker-cb", key)));
let resp = ui.add_enabled_ui(transformish, |ui| widgets::checkbox(ui, cr, on && transformish, &t, egui::Id::unique(("tracker-cb", key)))).inner;
app.auto.add(&format!("tracker.{key}"), cr, text);
p.text(pos2(cr.max.x + 4.0, cr.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), if transformish { t.text } else { t.text_faint });
if resp.clicked() && transformish {
if resp.changed() && transformish {
run(app, &ctx, "track.setType", with(json!({ key: !on })));
}
xx += 84.0;
@@ -517,20 +517,20 @@ pub fn options_dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens)
ui.add_space(8.0);
ui.label(egui::RichText::new(crate::i18n::tr("Process Before Match")).font(Tokens::medium(12.5)));
ui.horizontal(|ui| {
let r = ui.checkbox(&mut d.blur_on, crate::i18n::tr("Blur"));
let r = crate::widgets::labelled_checkbox(ui, &mut d.blur_on, crate::i18n::tr("Blur"));
app.auto.add("dialog.trackOptions.blurOn", r.rect, "Blur");
let r =
ui.add_enabled(d.blur_on, crate::widgets::drag_value(egui::DragValue::new(&mut d.opts.blur).speed(0.1).range(0.5..=50.0).suffix(" pixels")));
app.auto.add("dialog.trackOptions.blur", r.rect, "Blur pixels");
let r = ui.checkbox(&mut d.opts.enhance, crate::i18n::tr("Enhance"));
let r = crate::widgets::labelled_checkbox(ui, &mut d.opts.enhance, crate::i18n::tr("Enhance"));
app.auto.add("dialog.trackOptions.enhance", r.rect, "Enhance");
});
ui.add_space(8.0);
let r = ui.checkbox(&mut d.opts.subpixel, crate::i18n::tr("Subpixel Positioning"));
let r = crate::widgets::labelled_checkbox(ui, &mut d.opts.subpixel, crate::i18n::tr("Subpixel Positioning"));
app.auto.add("dialog.trackOptions.subpixel", r.rect, "Subpixel Positioning");
let r = ui.checkbox(&mut d.opts.adapt_every_frame, crate::i18n::tr("Adapt Feature On Every Frame"));
let r = crate::widgets::labelled_checkbox(ui, &mut d.opts.adapt_every_frame, crate::i18n::tr("Adapt Feature On Every Frame"));
app.auto.add("dialog.trackOptions.adaptEveryFrame", r.rect, "Adapt Feature On Every Frame");
let r = ui.checkbox(&mut d.opts.track_shape, crate::i18n::tr("Follow Feature Rotation and Scale"));
let r = crate::widgets::labelled_checkbox(ui, &mut d.opts.track_shape, crate::i18n::tr("Follow Feature Rotation and Scale"));
app.auto.add("dialog.trackOptions.trackShape", r.rect, "Follow Feature Rotation and Scale");
ui.add_space(8.0);
ui.horizontal(|ui| {
+2 -1
View File
@@ -1674,8 +1674,9 @@ fn phase_clicked(app: &mut EffectcraftApp, ectx: &EvalCtx, resp: &egui::Response
let _ = app.session.execute("mask.convertVertex", json!({"layer": h.layer.0, "mask": h.mask, "index": h.index}));
} else if let Some(k) = ov::motion_key_at(&key_hits, pos) {
// A key showing direction handles becomes Linear; one without, Auto Bezier.
// Separated X / Y Position have no spatial interpolation to convert.
let curved = key_hits.iter().any(|h| h.tangent.is_some() && h.layer == k.layer && h.prop == k.prop && h.time == k.time);
if select_motion_key(app, &k) {
if k.axes.is_none() && select_motion_key(app, &k) {
let spatial = if curved { "linear" } else { "autoBezier" };
let _ = app.session.execute("keys.interpolation", json!({"spatial": spatial}));
}
+1 -1
View File
@@ -25,7 +25,7 @@ pub(super) fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec
if let Some(crate::panels::DragPayload::Item(dragged)) = egui::DragAndDrop::payload::<crate::panels::DragPayload>(&ctx).as_deref()
&& ui.rect_contains_pointer(rect)
{
let items: Vec<effectcraft_engine::project::ItemId> = crate::panels::project_drop_items(app, *dragged)
let items: Vec<effectcraft_engine::project::ItemId> = crate::panels::project_drop_items(app, *dragged, None)
.into_iter()
.map(effectcraft_engine::project::ItemId)
.filter(|id| {
+8 -4
View File
@@ -137,14 +137,18 @@ pub(super) fn draw_rigs(
/// layer, or a new shape layer when nothing is selected. The placed point snaps.
/// Add a Position keyframe at a motion path's frame, keeping the path where it is.
pub(super) fn add_motion_key(app: &mut EffectcraftApp, f: ov::PathFrame) {
if let Err(e) = app.session.execute("prop.addKey", json!({"layer": f.layer.0, "prop": f.prop, "time": f.time.seconds()})) {
app.ui.status = e.to_string();
// Separated dimensions: the key goes on X and Y Position.
for prop in f.axes.map_or_else(|| vec![f.prop], Vec::from) {
if let Err(e) = app.session.execute("prop.addKey", json!({"layer": f.layer.0, "prop": prop, "time": f.time.seconds()})) {
app.ui.status = e.to_string();
}
}
}
/// Select only the motion path's keyframe `k` (what the vertex tools act on).
/// Select only the motion path's keyframe `k` (what the vertex tools act on; with separated
/// dimensions, the X and Y Position keys at its time).
pub(super) fn select_motion_key(app: &mut EffectcraftApp, k: &ov::KeyHit) -> bool {
app.session.execute("keys.select", json!({"keys": [{"layer": k.layer.0, "prop": k.prop, "time": k.time.seconds()}]})).is_ok()
app.session.execute("keys.select", json!({"keys": k.select_entries()})).is_ok()
}
/// The pointer for a Pen slot tool: the tool's own pen, the Pen showing Add Vertex's over a path
+5 -1
View File
@@ -54,7 +54,11 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Pain
}
// New layers go above the current selection, so insert in reverse
// to preserve the order of the dragged Project items.
crate::panels::project_drop_items(app, *item).into_iter().rev().map(|item| ("layer.addItem", json!({"item": item, "position": at}))).collect()
crate::panels::project_drop_items(app, *item, app.session.state.active_comp)
.into_iter()
.rev()
.map(|item| ("layer.addItem", json!({"item": item, "position": at})))
.collect()
}
DragPayload::Files(paths) => vec![("mediaBrowser.import", json!({"paths": paths, "addToComp": true, "position": at}))],
DragPayload::Property { .. } => vec![],
+122 -26
View File
@@ -7,7 +7,7 @@
use effectcraft_engine::geom::{Mat3, vec2 as gv2};
use effectcraft_engine::keyframe::ShapePath;
use effectcraft_engine::project::{GroupKind, Layer, LayerId};
use effectcraft_engine::project::{GroupKind, Layer, LayerId, Property};
use effectcraft_engine::render::EvalCtx;
use effectcraft_engine::time::Tick;
use effectcraft_engine::{KeyRef, VertexRef};
@@ -309,6 +309,23 @@ pub(crate) struct KeyHit {
pub value: [f64; 3],
/// Parent space → comp.
pub p2c: Mat3,
/// Separated dimensions: X and Y Position's uids, and whether each has a key at `time`.
pub axes: Option<[(u64, bool); 2]>,
}
impl KeyHit {
/// The keys this dot stands for: Position's, or the X and Y Position keys at its time.
pub(crate) fn key_refs(&self) -> Vec<KeyRef> {
match self.axes {
Some(axes) => axes.iter().filter(|(_, has)| *has).map(|&(prop, _)| KeyRef { layer: self.layer, prop, time: self.time }).collect(),
None => vec![KeyRef { layer: self.layer, prop: self.prop, time: self.time }],
}
}
/// [`Self::key_refs`] as `keys.select` entries.
pub(crate) fn select_entries(&self) -> Vec<serde_json::Value> {
self.key_refs().iter().map(|k| json!({"layer": k.layer.0, "prop": k.prop, "time": k.time.seconds()})).collect()
}
}
/// A frame on a motion path (one of its dots): where the Pen and Add Vertex tools add a
@@ -320,6 +337,21 @@ pub(crate) struct PathFrame {
/// Layer time of the frame.
pub time: Tick,
pub pos: Pos2,
/// Separated dimensions: X and Y Position's uids, which get the key instead of `prop`.
pub axes: Option<[u64; 2]>,
}
/// Layer times of the keys of separated X and Y Position together, in order.
fn separated_key_times(x: &Property, y: &Property) -> Vec<Tick> {
let mut times: Vec<Tick> = x.keys.iter().chain(y.keys.iter()).map(|k| k.time).collect();
times.sort();
times.dedup();
times
}
/// Separated X and Y Position at layer time `t` (z 0: motion paths are for 2D layers).
fn separated_value(x: &Property, y: &Property, t: Tick) -> [f64; 3] {
[x.value_at(t).as_f64(), y.value_at(t).as_f64(), 0.0]
}
/// The keyframe dot on a motion path under `pos`.
@@ -361,44 +393,61 @@ pub(crate) fn motion_path(
hits: &mut Vec<KeyHit>,
frames: &mut Vec<PathFrame>,
) {
let Some(pos) = l.transform().and_then(|tr| tr.get("position")) else { return };
if pos.keys.len() < 2 || l.is_3d() {
let Some(tr) = l.transform() else { return };
let Some(pos) = tr.get("position") else { return };
// Separate Dimensions: the layer follows X / Y Position, so the path is drawn from their keys.
let sep = tr.get("positionX").zip(tr.get("positionY"));
let key_times: Vec<Tick> = match sep {
Some((x, y)) => separated_key_times(x, y),
None => pos.keys.iter().map(|k| k.time).collect(),
};
if key_times.len() < 2 || l.is_3d() {
return;
}
let prop = sep.map_or(pos.uid, |(x, _)| x.uid);
let frame_axes = sep.map(|(x, y)| [x.uid, y.uid]);
let value_at = |t: Tick| match sep {
Some((x, y)) => separated_value(x, y, t),
None => pos.value_at(t).as_vec3(),
};
let p2c = parent_to_comp(ectx, l);
let to_scr = |v: [f64; 3]| {
let c = p2c.apply(gv2(v[0], v[1]));
map.to_screen([c.x, c.y])
};
// Settings ▸ Composition ▸ Motion Path: all keyframes, none, or a window around the CTI.
let times: Vec<Tick> = pos.keys.iter().map(|k| k.time).collect();
let Some((lt0, lt1)) = app.session.prefs.motion_path_span(&times, l.layer_time(ectx.time)) else { return };
let Some((lt0, lt1)) = app.session.prefs.motion_path_span(&key_times, l.layer_time(ectx.time)) else { return };
let fd = ectx.comp.frame_duration();
let mut pts = vec![];
let mut lt = lt0;
while lt <= lt1 && pts.len() < 20_000 {
let q = to_scr(pos.value_at(lt).as_vec3());
frames.push(PathFrame { layer: l.id, prop: pos.uid, time: lt, pos: q });
let q = to_scr(value_at(lt));
frames.push(PathFrame { layer: l.id, prop, time: lt, pos: q, axes: frame_axes });
pts.push(q);
lt += fd;
}
pts.push(to_scr(pos.value_at(lt1).as_vec3()));
pts.push(to_scr(value_at(lt1)));
let faint = col.gamma_multiply(0.8);
painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, faint)));
for q in &pts {
painter.circle_filled(*q, 1.2, faint);
}
let sel = &app.session.state.selected_keys;
for (i, k) in pos.keys.iter().enumerate() {
if k.time < lt0 || k.time > lt1 {
for (i, kt) in key_times.iter().enumerate() {
if *kt < lt0 || *kt > lt1 {
continue;
}
let v = k.value.as_vec3();
// The combined Position key at this time; none with separated dimensions.
let key = pos.keys.get(i).filter(|_| sep.is_none());
let v = key.map_or_else(|| value_at(*kt), |k| k.value.as_vec3());
let s = to_scr(v);
let selected = sel.contains(&KeyRef { layer: l.id, prop: pos.uid, time: k.time });
let axes = sep.map(|(x, y)| [x, y].map(|a| (a.uid, a.keys.iter().any(|k| k.time == *kt))));
let hit = KeyHit { layer: l.id, prop, time: *kt, pos: s, tangent: None, value: v, p2c, axes };
let selected = hit.key_refs().iter().any(|k| sel.contains(k));
// A muted position key is off the motion path: no tangents, and a dimmed point.
let kc = if k.muted { col.gamma_multiply(if selected { 0.55 } else { 0.35 }) } else { col };
if !k.muted {
let kc = if key.is_some_and(|k| k.muted) { col.gamma_multiply(if selected { 0.55 } else { 0.35 }) } else { col };
// Separated X / Y have no spatial tangents.
if let Some(k) = key.filter(|k| !k.muted) {
// Auto-Bezier keys show handles on a side the path leaves straight too (a two-key path's
// ends), to drag it into a curve; Linear keys (no tangents) show none.
let (tin, tout) = if k.spatial_auto {
@@ -416,7 +465,7 @@ pub(crate) fn motion_path(
painter.circle_filled(h, 3.0, kc);
let side = if out { "out" } else { "in" };
app.auto.add(&format!("viewer.motionPath.{}.{i}.{side}", l.id.0), Rect::from_center_size(h, vec2(8.0, 8.0)), "Spatial tangent");
hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: h, tangent: Some(out), value: v, p2c });
hits.push(KeyHit { layer: l.id, prop, time: *kt, pos: h, tangent: Some(out), value: v, p2c, axes: None });
}
}
let r = Rect::from_center_size(s, vec2(7.0, 7.0));
@@ -427,7 +476,7 @@ pub(crate) fn motion_path(
painter.rect_stroke(r, 0.0, Stroke::new(1.0, kc), StrokeKind::Inside);
}
app.auto.add(&format!("viewer.motionPath.{}.{i}", l.id.0), r.expand(2.0), "Position keyframe");
hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: s, tangent: None, value: v, p2c });
hits.push(hit);
}
}
@@ -544,7 +593,7 @@ fn point_in_quad(p: Pos2, q: &[Pos2; 4]) -> bool {
#[derive(Clone, Debug)]
pub(crate) enum Drag {
/// A motion-path keyframe: its value moves with the pointer (parent space).
Key { layer: LayerId, prop: u64, time: Tick, start: [f64; 3], press: [f64; 2], inv: Mat3 },
Key { layer: LayerId, prop: u64, time: Tick, start: [f64; 3], press: [f64; 2], inv: Mat3, axes: Option<[u64; 2]> },
/// A spatial tangent handle of a motion-path key.
KeyTangent { layer: LayerId, prop: u64, time: Tick, out: bool, key: [f64; 3], inv: Mat3 },
/// Free transform: 0 move, 1 scale (anchor, axes), 2 rotate about `anchor`.
@@ -569,11 +618,17 @@ pub(crate) fn begin_key_drag(app: &mut EffectcraftApp, hits: &[KeyHit], press: P
match h.tangent {
Some(out) => Some(Drag::KeyTangent { layer: h.layer, prop: h.prop, time: h.time, out, key: h.value, inv }),
None => {
let k = KeyRef { layer: h.layer, prop: h.prop, time: h.time };
if !app.session.state.selected_keys.contains(&k) || shift {
let _ = app.session.execute("keys.select", json!({"keys": [{"layer": h.layer.0, "prop": h.prop, "time": h.time.seconds()}], "add": shift}));
// Separated dimensions: both X and Y need a key here before the drag moves them.
let mut h = *h;
for (uid, has) in h.axes.iter_mut().flatten().filter(|(_, has)| !*has) {
let _ = app.session.execute("prop.addKey", json!({"layer": h.layer.0, "prop": *uid, "time": h.time.seconds()}));
*has = true;
}
Some(Drag::Key { layer: h.layer, prop: h.prop, time: h.time, start: h.value, press: cpt, inv })
if !h.key_refs().iter().all(|k| app.session.state.selected_keys.contains(k)) || shift {
let _ = app.session.execute("keys.select", json!({"keys": h.select_entries(), "add": shift}));
}
let axes = h.axes.map(|a| a.map(|(uid, _)| uid));
Some(Drag::Key { layer: h.layer, prop: h.prop, time: h.time, start: h.value, press: cpt, inv, axes })
}
}
}
@@ -639,17 +694,22 @@ pub(crate) fn update(
snap: &mut dyn FnMut(&EffectcraftApp, [f64; 2], LayerId) -> [f64; 2],
) -> Option<Drag> {
match d.clone() {
Drag::Key { layer, prop, time, start, press, inv } => {
Drag::Key { layer, prop, time, start, press, inv, axes } => {
// Snap the key's on-screen position like a layer drag.
let start_c = inv.inverse().map(|m| m.apply(gv2(start[0], start[1]))).map(|v| [v.x, v.y]).unwrap_or([start[0], start[1]]);
let mut target = [start_c[0] + cpt[0] - press[0], start_c[1] + cpt[1] - press[1]];
let s = snap(app, target, layer);
target = [target[0] + s[0], target[1] + s[1]];
let p = inv.apply(gv2(target[0], target[1]));
let v = json!([p.x, p.y, start[2]]);
let r = app.session.execute("keys.set", json!({"layer": layer.0, "prop": prop, "time": time.seconds(), "value": v, "merge": merge}));
if let Err(e) = r {
app.ui.status = e.to_string();
let edits = match axes {
Some([ux, uy]) => vec![(ux, json!(p.x)), (uy, json!(p.y))],
None => vec![(prop, json!([p.x, p.y, start[2]]))],
};
for (uid, v) in edits {
let r = app.session.execute("keys.set", json!({"layer": layer.0, "prop": uid, "time": time.seconds(), "value": v, "merge": merge}));
if let Err(e) = r {
app.ui.status = e.to_string();
}
}
None
}
@@ -714,6 +774,42 @@ pub(crate) fn update(
mod tests {
use super::*;
fn scalar_prop(uid: u64, keys: &[(i64, f64)]) -> Property {
use effectcraft_engine::keyframe::{Keyframe, Value};
let mut p = Property::new(uid, "positionX", "X Position", Value::Scalar(0.0));
p.keys = keys.iter().map(|&(t, v)| Keyframe::new(Tick(t), Value::Scalar(v))).collect();
p
}
/// #2242: with separated dimensions the motion path follows the keys of X and Y Position.
#[test]
fn separated_motion_path_uses_x_and_y_keys() {
let x = scalar_prop(1, &[(0, 10.0), (100, 30.0)]);
let y = scalar_prop(2, &[(0, 0.0), (50, 20.0), (100, 0.0)]);
assert_eq!(separated_key_times(&x, &y), vec![Tick(0), Tick(50), Tick(100)]);
let mid = separated_value(&x, &y, Tick(50));
assert!((mid[0] - 20.0).abs() < 1e-9 && (mid[1] - 20.0).abs() < 1e-9 && mid[2] == 0.0, "{mid:?}");
assert_eq!(separated_value(&x, &y, Tick(100)), [30.0, 0.0, 0.0]);
}
/// #2242: a separated dot selects the X and Y keys at its time, not the nearest X key.
#[test]
fn separated_key_dot_refers_to_its_axis_keys() {
let mut h = KeyHit {
layer: LayerId(7),
prop: 1,
time: Tick(50),
pos: Pos2::ZERO,
tangent: None,
value: [0.0; 3],
p2c: Mat3::IDENTITY,
axes: Some([(1, false), (2, true)]),
};
assert_eq!(h.key_refs(), vec![KeyRef { layer: LayerId(7), prop: 2, time: Tick(50) }]);
h.axes = None;
assert_eq!(h.key_refs(), vec![KeyRef { layer: LayerId(7), prop: 1, time: Tick(50) }]);
}
fn info(vertices: Vec<[f64; 2]>, closed: bool) -> PathInfo {
let n = vertices.len();
let sp = ShapePath { vertices, in_tangents: vec![[0.0; 2]; n], out_tangents: vec![[0.0; 2]; n], closed, feather: Vec::new() };
+12 -8
View File
@@ -1362,14 +1362,18 @@ fn key_event(
exec(app, "layer.setText", json!({"layer": sel.layer.0, "range": [r.start, r.end], "size": ns}));
}
// The app-wide shortcut dispatcher is skipped while this editor holds the
// keyboard, so the Deselect All chord (Cmd/Ctrl+Shift+A or F2 by default)
// must be matched here and run through the command dispatcher like undo
// above. Matching the command's current binding, not a fixed key, keeps
// rebound shortcuts working and gives every route one final state; the
// forwarding test also refuses chords a focused editor must never surrender
// (see `forwardable_during_text_edit`).
_ if crate::menus::chord_matches(&app.session, "edit.deselectAll", key, m) && crate::menus::forwardable_during_text_edit(m, key) => {
let _ = crate::menus::invoke(app, ctx, "edit.deselectAll", json!({}));
// keyboard, so commands that end the edit by moving the selection — Deselect
// All (Cmd/Ctrl+Shift+A or F2 by default) and Duplicate (Cmd/Ctrl+D) — must be
// matched here and run through the command dispatcher like undo above.
// Matching each command's current binding, not a fixed key, keeps rebound
// shortcuts working and gives every route one final state; a chord bound to
// both commands resolves in list order. The forwarding test also refuses
// chords a focused editor must never surrender (see
// `forwardable_during_text_edit`).
_ if crate::menus::forwardable_during_text_edit(m, key)
&& let Some(cmd_id) = ["edit.deselectAll", "edit.duplicate"].into_iter().find(|id| crate::menus::chord_matches(&app.session, id, key, m)) =>
{
let _ = crate::menus::invoke(app, ctx, cmd_id, json!({}));
}
_ => {}
}
+12 -2
View File
@@ -86,7 +86,17 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) {
.find(|e| e.id == "viewer.area")
.map(|e| Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])));
let at = if proj.is_empty() {
// Dropping a file on the Timeline adds it to the composition the panel shows, as it does
// on the viewer (#2588); the import decides the layer's position.
let over_timeline = ctx.input(|i| i.pointer.hover_pos()).is_some_and(|p| {
app.auto
.elements
.iter()
.find(|e| e.id == "panel.Timeline")
.is_some_and(|e| Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])).contains(p))
});
let at = if proj.is_empty() && !over_timeline {
ctx.input(|i| i.pointer.hover_pos()).filter(|p| viewer.is_some_and(|v| v.contains(*p))).and_then(|p| crate::panels::viewer::screen_to_comp(&ctx, p))
} else {
None
@@ -101,7 +111,7 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) {
let mut params = json!({
"paths": rest,
"drag": true,
"addToComp": at.is_some(),
"addToComp": at.is_some() || over_timeline,
"position": at,
"background": true
});
+18 -4
View File
@@ -308,8 +308,8 @@ impl Default for ViewerState {
impl TimelineState {
/// Whether property `uid`'s chain link (Constrain Proportions) is on. One that starts linked
/// (Scale, Mask Feather) records a click in `unlinked`; one that starts unlinked (shape Size)
/// records it in `linked`.
/// (Scale, Mask Feather) records a click in `unlinked`; one that starts unlinked (shape Size,
/// Audio Levels) records it in `linked`.
pub fn is_linked(&self, uid: u64, linked_by_default: bool) -> bool {
if linked_by_default { !self.unlinked.contains(&uid) } else { self.linked.contains(&uid) }
}
@@ -322,6 +322,16 @@ impl TimelineState {
}
}
/// Forget the chain links when the project was replaced (new or opened): they are keyed by
/// property uid, which the next project reuses, so a link turned on must not carry into it.
pub fn scope_links(&mut self, project_epoch: u64) {
if self.link_epoch != project_epoch {
self.link_epoch = project_epoch;
self.unlinked.clear();
self.linked.clear();
}
}
/// Show `kinds` on `layers` (twirled open; empty: twirled closed) and remember it as the last
/// reveal. Other layers keep theirs.
pub fn apply_reveal(&mut self, layers: &[u64], kinds: Vec<String>) {
@@ -429,10 +439,13 @@ pub struct TimelineState {
/// was turned off.
#[serde(default)]
pub unlinked: BTreeSet<u64>,
/// Shape Rectangle and Ellipse Path Size properties whose chain link (off by default) was
/// turned on (#2489).
/// Shape Rectangle and Ellipse Path Size (#2489) and Audio Levels (#2485) properties whose
/// chain link (off by default) was turned on.
#[serde(default)]
pub linked: BTreeSet<u64>,
/// The project (its epoch) `unlinked` and `linked` belong to.
#[serde(skip)]
pub link_epoch: u64,
/// Visible optional columns (column header right-click ▸ Columns): `av`, `keys`, `label`,
/// `num`, `comment`, `switches`, `parent`, `in`, `out`, `duration`, `stretch`. The name
/// column is always shown; Modes follows `show_modes` (F4).
@@ -486,6 +499,7 @@ impl Default for TimelineState {
expr_lines: BTreeMap::new(),
unlinked: BTreeSet::new(),
linked: BTreeSet::new(),
link_epoch: 0,
columns: default_tl_columns(),
source_name: false,
scroll_to: None,
+1 -1
View File
@@ -21,7 +21,7 @@ impl Default for Runtime {
fn default() -> Self {
Self {
#[cfg(not(target_arch = "wasm32"))]
updater: Updater::new(&craft_update::suite::EFFECTCRAFT, env!("CARGO_PKG_VERSION")),
updater: Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")),
#[cfg(not(target_arch = "wasm32"))]
startup_check: false,
#[cfg(not(target_arch = "wasm32"))]
+434 -7
View File
@@ -260,6 +260,7 @@ pub fn twirl(ui: &mut Ui, rect: Rect, open: bool, id: egui::Id, t: &Tokens) -> R
vec![c + vec2(-0.8 * k, -1.6 * k), c + vec2(0.8 * k, 0.0), c + vec2(-0.8 * k, 1.6 * k)]
};
ui.painter().add(egui::Shape::line(pts, Stroke::new(1.4, col)));
focus_outline(ui, &resp, t);
resp
}
@@ -273,6 +274,7 @@ pub fn icon_toggle(ui: &mut Ui, rect: Rect, icon: Icon, on: bool, t: &Tokens, id
if on || resp.hovered() {
icons::paint(ui.painter(), rect.shrink(rect.width() * 0.18), icon, if on { t.icon } else { t.text_faint });
}
focus_outline(ui, &resp, t);
resp
}
@@ -297,6 +299,8 @@ pub fn icon_button(ui: &mut Ui, rect: Rect, icon: Icon, active: bool, t: &Tokens
t.icon
};
icons::paint(ui.painter(), rect.shrink(rect.width() * 0.22), icon, col);
// An icon has no text: the caller names the button.
crate::a11y::role(&resp, egui::accesskit::Role::Button);
focus_outline(ui, &resp, t);
resp
}
@@ -341,7 +345,7 @@ fn field(ui: &mut Ui, rect: Rect, text: &mut String, hint: &str, t: &Tokens, sea
}
let inner = Rect::from_min_max(pos2(rect.min.x + if search { 22.0 } else { 9.0 }, rect.min.y + 1.0), pos2(rect.max.x - 8.0, rect.max.y - 1.0));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner));
child.add(
let resp = child.add(
egui::TextEdit::singleline(text)
.hint_text(hint)
.frame(egui::Frame::NONE)
@@ -350,7 +354,10 @@ fn field(ui: &mut Ui, rect: Rect, text: &mut String, hint: &str, t: &Tokens, sea
.min_size(inner.size())
.font(Tokens::ui(12.0))
.align(egui::Align2::LEFT_CENTER),
)
);
// The hint is the field's visible name (the caller can give it a better one).
crate::a11y::name(&resp, hint);
resp
}
/// Dropdown-looking control ("Full ▾").
@@ -390,6 +397,10 @@ pub fn dropdown_tip(ui: &mut Ui, rect: Rect, text: &str, tip: Option<&str>, t: &
};
ui.painter().text(pos2(rect.min.x + 7.0, rect.center().y), Align2::LEFT_CENTER, shown, Tokens::ui(11.5), t.text);
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.max.x - 9.0, rect.center().y), vec2(8.0, 8.0)), Icon::ChevronDown, t.text_dim);
// A drop-down showing `text`; the caller names what it sets.
crate::a11y::role(&resp, egui::accesskit::Role::ComboBox);
crate::a11y::value(&resp, text);
focus_outline(ui, &resp, t);
if let Some(tip) = tip {
return resp.on_hover_text(tip.to_string());
}
@@ -412,12 +423,178 @@ pub fn text_button(ui: &mut Ui, rect: Rect, text: &str, primary: bool, t: &Token
};
ui.painter().rect_filled(rect, rect.height() / 2.0, bg);
ui.painter().text(rect.center(), Align2::CENTER_CENTER, text, Tokens::medium(12.0), if primary { Color32::WHITE } else { t.text });
crate::a11y::role_name(&resp, egui::accesskit::Role::Button, text);
focus_outline(ui, &resp, t);
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);
@@ -425,6 +602,7 @@ pub fn checkbox(ui: &mut Ui, rect: Rect, on: bool, t: &Tokens, id: egui::Id) ->
let p = |x: f32, y: f32| pos2(b.min.x + x * b.width(), b.min.y + y * b.height());
ui.painter().line(vec![p(0.2, 0.52), p(0.42, 0.74), p(0.8, 0.28)], Stroke::new(1.6, Color32::WHITE));
}
focus_outline(ui, &resp, t);
resp
}
@@ -444,7 +622,9 @@ pub fn scroll_bar(ui: &mut Ui, auto: &mut Registry, key: &str, track: Rect, scro
let thumb_len = (len * len / (len + max.max(0.0)).max(1.0)).max(16.0).min(len).max(0.0);
let travel = (len - thumb_len).max(1.0);
let thumb_at = |s: f32| track.min[a] + (len - thumb_len) * if max > 0.0 { (s / max).clamp(0.0, 1.0) } else { 0.0 };
let resp = ui.interact(track.expand2(if vertical { vec2(3.0, 0.0) } else { vec2(0.0, 3.0) }), egui::Id::unique(key), Sense::drag());
// Dragged with the mouse only: a Tab stop that no key could move would be a trap. The panels
// scroll with their own keys (the arrows, Page Up / Page Down) and the wheel.
let resp = ui.interact(track.expand2(if vertical { vec2(3.0, 0.0) } else { vec2(0.0, 3.0) }), egui::Id::unique(key), Sense::DRAG);
let pressed = resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.any_pressed());
let mut s = scroll;
if let Some(p) = resp.interact_pointer_pos().filter(|p| pressed && !(thumb_at(scroll)..=thumb_at(scroll) + thumb_len).contains(&p[a])) {
@@ -488,6 +668,22 @@ impl PanelScroll {
Self { id, area, offset: offset.clamp(0.0, max.max(0.0)) }
}
/// Scroll the panel `id` (a [`PanelScroll`] kept under that id) so the content from `top` to
/// `bottom` is in view of a `view` tall list, from the next pass.
pub fn reveal(ctx: &egui::Context, id: egui::Id, top: f32, bottom: f32, view: f32) {
let (offset, max): (f32, f32) = ctx.data(|d| d.get_temp(id)).unwrap_or_default();
let wanted = if top < offset {
top
} else if bottom > offset + view {
bottom - view
} else {
offset
}
.max(0.0);
// (`max` is last frame's overflow; the next `end` measures it again.)
ctx.data_mut(|d| d.insert_temp(id, (wanted, max.max(wanted))));
}
/// The rows were `content` points tall: keep the offset, with the scroll bar registered for
/// automation as `key`.
pub fn end(self, ui: &mut Ui, auto: &mut Registry, key: &str, content: f32, t: &Tokens) {
@@ -572,6 +768,30 @@ pub fn label_popup(
})
}
/// This pass's input has no mouse press or release: whatever just happened (a control activated,
/// a list opened, an entry chosen) came from the keyboard or an assistive technology.
pub fn by_keyboard(ui: &Ui) -> bool {
!ui.input(|i| i.pointer.any_pressed() || i.pointer.any_released())
}
/// Put the keyboard on `entry` of the popup list `id` the pass the list opens, so Up / Down and
/// Enter work it and Escape closes it. (The pass count tells a fresh opening from a list that has
/// been open for some passes.)
pub fn focus_when_opened(ui: &Ui, id: egui::Id, entry: &Response) {
let key = id.with("focus-pass");
let pass = ui.ctx().cumulative_pass_nr();
let previous = ui.data_mut(|d| {
let previous = d.get_temp::<u64>(key);
d.insert_temp(key, pass);
previous
});
// A list opened with the mouse keeps the keyboard where it was: a widget with the focus
// pauses the application's shortcuts (Space, Ctrl+Z) until the focus is lost.
if previous.is_none_or(|p| p.saturating_add(1) < pass) && by_keyboard(ui) {
entry.request_focus();
}
}
/// Show a popup menu anchored at `pos` with string options; returns the chosen index.
pub fn popup_menu(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, options: &[String], current: Option<usize>) -> Option<usize> {
if !popup_is_open(ui, id) {
@@ -581,11 +801,20 @@ pub fn popup_menu(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, options: &[String]
popup_list(ui, id, pos, Rect::NOTHING, options.len().saturating_sub(seps), seps, |ui| {
ui.set_min_width(160.0);
let mut chosen = None;
let mut first = true;
for (i, o) in options.iter().enumerate() {
if o == "-" {
ui.separator();
} else if ui.selectable_label(current == Some(i), o).clicked() {
chosen = Some(i);
} else {
let r = ui.selectable_label(current == Some(i), o);
// The keyboard starts on the current entry (the first one, with none current).
if current == Some(i) || (current.is_none() && first) {
focus_when_opened(ui, id, &r);
}
first = false;
if r.clicked() {
chosen = Some(i);
}
}
}
chosen
@@ -820,10 +1049,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() {
+4
View File
@@ -15,6 +15,7 @@ mod ui_blank_context_menus;
mod ui_camera_tracker;
mod ui_character_eyedropper;
mod ui_character_stroke_order;
mod ui_checkbox_drag;
mod ui_color_hex;
mod ui_comp_presets;
mod ui_control_modifiers;
@@ -68,6 +69,7 @@ mod ui_render_queue;
mod ui_script_console;
mod ui_scriptui;
mod ui_scrolling;
mod ui_self_drop;
mod ui_settings_info;
mod ui_tabs;
mod ui_tail;
@@ -75,7 +77,9 @@ mod ui_text_edit;
mod ui_text_range_handles;
mod ui_theme_favorites;
mod ui_time_zoom_controls;
mod ui_timeline_audio_levels;
mod ui_timeline_effect_label;
mod ui_timeline_group_select;
mod ui_timeline_pan;
mod ui_timeline_shapes;
mod ui_timeline_switches;
@@ -0,0 +1,54 @@
//! #1190: the shared drag gesture also reaches real dialog controls.
use effectcraft_engine::Session;
use effectcraft_ui_egui::{EffectcraftApp, menus};
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::{Harness, kittest::Queryable as _};
use serde_json::json;
fn pointer(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, pressed: Option<bool>) {
h.event(Event::PointerMoved(at));
h.step();
if let Some(pressed) = pressed {
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(2);
}
#[test]
fn view_options_drag_checks_and_unchecks_neighbours() {
let mut session = Session::default();
session.prefs.disk.disk_cache_enabled = false;
session.execute("comp.new", json!({"width":32,"height":32,"duration":1})).unwrap();
let mut h = Harness::builder().with_size(vec2(1200.0, 900.0)).build_eframe(|_| EffectcraftApp::new(session));
h.state_mut().ui.viewer.rulers = false;
h.state_mut().ui.viewer.guides = true;
h.state_mut().ui.viewer.grid = false;
h.run_steps(3);
let ctx = h.ctx.clone();
menus::invoke(h.state_mut(), &ctx, "view.options", json!({})).unwrap();
h.run_steps(3);
let rulers = h.get_by_label("Rulers").rect().center();
let guides = h.get_by_label("Guides").rect().center();
let grid = h.get_by_label("Grid").rect().center();
pointer(&mut h, rulers, Some(true));
assert!(h.state().ui.viewer.rulers);
pointer(&mut h, guides, None);
assert!(h.state().ui.viewer.guides, "an already-checked neighbour stays checked");
pointer(&mut h, grid, None);
assert!(h.state().ui.viewer.grid);
pointer(&mut h, rulers, None);
pointer(&mut h, rulers, Some(false));
assert!(h.state().ui.viewer.rulers, "revisiting and releasing does not undo the change");
if let Ok(path) = std::env::var("CHECKBOX_REVIEW_PNG") {
h.render().unwrap().save(path).unwrap();
}
pointer(&mut h, grid, Some(true));
pointer(&mut h, guides, None);
pointer(&mut h, rulers, None);
pointer(&mut h, rulers, Some(false));
let viewer = &h.state().ui.viewer;
assert!(!viewer.rulers && !viewer.guides && !viewer.grid);
pointer(&mut h, guides, None);
assert!(!h.state().ui.viewer.guides, "hover after release does not change a checkbox");
}
+13
View File
@@ -501,6 +501,19 @@ fn dropped_file_on_viewer_imports_and_adds_layer_at_pointer() {
assert!((p[0] - 80.0).abs() <= tol && (p[1] - 45.0).abs() <= tol, "{p:?}");
}
/// #2588: a file dropped on the Timeline joins the composition the panel shows, as it does on the
/// viewer; the import places the layer.
#[test]
fn dropped_file_on_the_timeline_becomes_a_layer() {
let (mut h, _) = harness();
h.state_mut().session.importer = Some(Arc::new(Gated { started: AtomicUsize::new(0), allow: Arc::new(AtomicUsize::new(usize::MAX)) }));
h.event(Event::PointerMoved(rect(&h, "panel.Timeline").center()));
h.step();
h.input_mut().dropped_files.push(Arc::new(Dropped(std::env::temp_dir().join("timeline-drop.mp4"))));
h.run_steps(2);
step_until(&mut h, |h| h.state().session.active_comp().is_some_and(|c| c.layers.iter().any(|l| l.name == "timeline-drop.mp4")));
}
/// #678: a dropped Photoshop file asks how to import it, as File ▸ Import ▸ File… does; the
/// Import Kind starts at Settings ▸ Import ▸ Default Drag Import As, and OK imports the drop.
#[test]
+72
View File
@@ -77,3 +77,75 @@ fn open_graph_toggle_twice(h: &mut Harness<'_, EffectcraftApp>) {
assert!(!h.state().ui.timeline.graph_editor);
open_graph(h);
}
#[test]
fn locked_graph_keys_preserve_the_existing_selection_and_keyframes() {
use egui::{Event, Modifiers, PointerButton, pos2, vec2};
for (button, modifiers, drag) in [
(PointerButton::Primary, Modifiers::NONE, false),
(PointerButton::Primary, Modifiers::SHIFT, false),
(PointerButton::Primary, Modifiers::COMMAND, false),
(PointerButton::Secondary, Modifiers::NONE, false),
(PointerButton::Primary, Modifiers::NONE, true),
] {
let (mut h, layer, uid) = harness(true);
let s = &mut h.state_mut().session;
let locked = s.execute("layer.newSolid", json!({"name": "Locked", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
for (time, value) in [(9.0, 100.0), (11.0, 200.0)] {
s.execute("prop.addKey", json!({"layer": locked, "path": "transform/rotation", "time": time, "value": value})).unwrap();
}
let locked_id = effectcraft_engine::project::LayerId(locked);
let locked_uid = s.active_comp().unwrap().layer(locked_id).unwrap().props.prop("transform/rotation").unwrap().uid;
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": true})).unwrap();
s.execute("keys.select", json!({"keys": [{"layer": layer, "prop": uid, "time": 12.0}]})).unwrap();
s.state.selected_props = vec![(effectcraft_engine::project::LayerId(layer), uid), (locked_id, locked_uid)];
let selection = s.state.selected_keys.clone();
let before = s.project.clone();
open_graph(&mut h);
h.state_mut().ui.timeline.graph_auto_zoom = false;
h.state_mut().ui.timeline.graph_range = Some((0.0, 800.0));
h.run_steps(3);
let [x, y, w, height] = h.state().auto.find(&format!("timeline.graph.key.{locked_uid}.0.0")).expect("locked key drawn").rect;
let point = pos2(x + w / 2.0, y + height / 2.0);
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
h.input_mut().events.push(Event::PointerMoved(point));
h.input_mut().events.push(Event::PointerButton { pos: point, button, pressed: true, modifiers });
h.step();
let end = if drag { point + vec2(30.0, -20.0) } else { point };
h.input_mut().events.push(Event::PointerMoved(end));
h.step();
h.input_mut().events.push(Event::PointerButton { pos: end, button, pressed: false, modifiers });
h.run_steps(3);
assert_eq!(h.state().session.state.selected_keys, selection, "{button:?}, {modifiers:?}, drag={drag}");
assert_eq!(
serde_json::to_value(&h.state().session.project).unwrap(),
serde_json::to_value(&before).unwrap(),
"locked gestures must not edit another key"
);
}
}
#[test]
fn an_unlocked_graph_keys_menu_edits_the_clicked_key() {
use egui::{Event, Modifiers, PointerButton, pos2};
use egui_kittest::kittest::Queryable;
let (mut h, layer, uid) = harness(true);
h.state_mut().session.execute("keys.select", json!({"keys": [{"layer": layer, "prop": uid, "time": 12.0}]})).unwrap();
open_graph(&mut h);
let [x, y, w, height] = h.state().auto.find(&format!("timeline.graph.key.{uid}.0.0")).unwrap().rect;
let at = pos2(x + w / 2.0, y + height / 2.0);
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerMoved(at));
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(3);
let selected = &h.state().session.state.selected_keys;
assert_eq!(selected.len(), 1);
assert_eq!(selected[0].time.seconds(), 8.0);
h.get_by_label("Easy Ease").click();
h.run_steps(3);
let prop = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.find(uid).unwrap();
assert_eq!(prop.keys[0].out_interp, effectcraft_engine::keyframe::Interp::Bezier);
assert_eq!(prop.keys[1].out_interp, effectcraft_engine::keyframe::Interp::Linear);
}
@@ -53,8 +53,7 @@ fn entry_for(h: &Harness<'_, EffectcraftApp>, layer: u64) -> String {
format!("{}. {}", i + 1, comp.layers[i].name)
}
/// Puts the layer-valued `prop` of `layer` back to `value` with a direct `prop.set` (no merge key,
/// so the pick below records its own step instead of folding into a picker write), picks `none`
/// Puts the layer-valued `prop` of `layer` back to `value` with a direct `prop.set`, picks `none`
/// from the drop-down at `value_id`, and checks the clear takes one undo step that restores `value`.
#[track_caller]
fn none_pick_clears(h: &mut Harness<'_, EffectcraftApp>, value_id: &str, none: &str, layer: u64, prop: u64, value: u64) {
@@ -73,6 +72,24 @@ fn none_pick_clears(h: &mut Harness<'_, EffectcraftApp>, value_id: &str, none: &
assert_eq!(layer_value(h, layer, prop), Some(value), "undo restores the selected layer");
}
/// #2152: two picks in a row from the drop-down at `value_id` (`first`, the layer `value`, then
/// `none`) are two undo steps: one Undo goes back to the first pick, not past it.
#[track_caller]
fn picks_are_separate_steps(h: &mut Harness<'_, EffectcraftApp>, value_id: &str, first: &str, none: &str, layer: u64, prop: u64, value: u64) {
let steps = h.state().session.history.undo.len();
for entry in [first, none] {
let at = rect_of(h, value_id).center();
click(h, at);
h.get_by_role_and_label(egui::accesskit::Role::Button, entry).click();
h.run_steps(3);
}
assert_eq!(layer_value(h, layer, prop), None);
assert_eq!(h.state().session.history.undo.len(), steps + 2, "each pick is its own undo step");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
assert_eq!(layer_value(h, layer, prop), Some(value), "one undo goes back to the first pick");
}
/// A light's Source (a layer-valued Light Options property) picks a comp layer from the
/// Timeline and undo restores None.
#[test]
@@ -138,6 +155,9 @@ fn timeline_source_drop_down_picks_the_layer_and_undo_restores_none() {
assert_eq!(layer_value(&h, light, source), None, "undo restores None");
none_pick_clears(&mut h, &value_id, "None", light, source, sky);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
picks_are_separate_steps(&mut h, &value_id, &entry, "None", light, source, sky);
}
/// The shared widget is the one Effect Controls already used: a Displacement Map's layer
@@ -176,6 +196,9 @@ fn effect_controls_layer_param_picks_through_the_shared_widget() {
assert_eq!(layer_value(&h, base, prop), None, "undo restores None");
none_pick_clears(&mut h, &value_id, "None", base, prop, map);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
picks_are_separate_steps(&mut h, &value_id, &entry, "None", base, prop, map);
}
/// The visual record (the `ui_3d` ignored-snapshot convention): the Source drop-down open over
+75
View File
@@ -0,0 +1,75 @@
//! #2589: a Project selection that contains the composition being dropped into must not fail the
//! whole drop. Rendering writes a file named after the composition, so the Project panel holds
//! both and selects both; the composition itself is not a layer it can hold.
use effectcraft_engine::Session;
use effectcraft_ui_egui::EffectcraftApp;
use egui::{Event, Modifiers, Pos2, Rect, pos2, vec2};
use egui_kittest::Harness;
use serde_json::json;
/// Compositions "Main" (active, 4 s at 30 fps, one solid) and "Clip".
fn harness() -> (Harness<'static, EffectcraftApp>, u64, u64) {
let mut s = Session::default();
let clip = s.execute("comp.new", json!({"name": "Clip", "width": 160, "height": 90, "frameRate": 30, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
let main = s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap()["comp"].as_u64().unwrap();
s.execute("layer.newSolid", json!({"name": "Bottom", "color": "#406080"})).unwrap();
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
(h, main, clip)
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone();
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
}
/// Press on `from`, move to `to` in steps, release there.
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
h.event(Event::PointerMoved(from));
h.step();
h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
for k in 1..=10 {
h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 10.0)));
h.step();
}
h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
fn stack(h: &Harness<'_, EffectcraftApp>) -> Vec<String> {
h.state().session.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect()
}
fn layer_id(h: &Harness<'_, EffectcraftApp>, name: &str) -> u64 {
h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == name).unwrap().id.0
}
/// Both the composition and the render output are selected: dragging the file onto the Timeline
/// still lands the file, and the composition is not carried along into itself.
#[test]
fn a_selection_holding_the_drop_target_does_not_fail_the_drop() {
let (mut h, main, clip) = harness();
h.state_mut().session.execute("project.select", json!({"items": [main, clip]})).unwrap();
h.run_steps(2);
let from = rect(&h, &format!("project.item.{clip}.name")).center();
let row = rect(&h, &format!("timeline.layer.{}.row", layer_id(&h, "Bottom")));
drag(&mut h, from, row.center());
assert_eq!(stack(&h), ["Bottom", "Clip"], "the composition the drop lands in is left out");
assert!(!h.state().ui.status.contains("contain itself"), "the drop did not fail: {}", h.state().ui.status);
}
/// Dragging the composition's own row onto its Timeline adds nothing and reports nothing.
#[test]
fn the_composition_dropped_on_itself_adds_nothing() {
let (mut h, main, _clip) = harness();
h.state_mut().session.execute("project.select", json!({"items": [main]})).unwrap();
h.run_steps(2);
let before = stack(&h);
let from = rect(&h, &format!("project.item.{main}.name")).center();
let row = rect(&h, &format!("timeline.layer.{}.row", layer_id(&h, "Bottom")));
drag(&mut h, from, row.center());
assert_eq!(stack(&h), before, "nothing is added");
assert!(!h.state().ui.status.contains("contain itself"), "no error: {}", h.state().ui.status);
}
+24
View File
@@ -175,6 +175,30 @@ fn layer_style_buttons_stay_in_view() {
}
}
/// #2057: Gradient Overlay's gradient has an Edit Gradient… button in the Layer Style dialog. It
/// opens the Gradient Editor on that property, and closing the editor goes back to the dialog.
#[test]
fn layer_style_gradient_opens_the_gradient_editor() {
for ok in [true, false] {
let mut h = harness(1400.0);
let lid = h.state().session.active_comp().unwrap().layers[0].id.0;
h.state_mut().session.execute("layer.select", json!({"layers": [lid]})).unwrap();
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.style.gradientOverlay", json!({})).unwrap();
h.run_steps(5);
let edit = in_view(&h, "dialog.layerStyle.value.gradientOverlay/colors");
click(&mut h, edit);
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::GradientEditor), "Edit Gradient… opened the editor");
effectcraft_ui_egui::panels::gradient_editor::finish(h.state_mut(), ok, &ctx);
h.run_steps(2);
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::LayerStyles), "closing the editor (OK {ok}) went back to the dialog");
// The dialog closes as before once the editor is gone.
effectcraft_ui_egui::panels::layer_styles_dialog::finish(h.state_mut(), true);
h.run_steps(2);
assert_eq!(h.state().dialog, None);
}
}
/// #1062: with the buttons kept at the bottom, the style's controls still scroll both ways, so
/// every one of them can be reached in the smallest window (960×600) at 200 % UI Scale, and in a
/// dialog opened in a large window that then narrows: here Noise, near the end of Drop Shadow,
+122 -1
View File
@@ -8,6 +8,7 @@ use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::dock::PanelKind;
use egui::{Event, Key, Modifiers, PointerButton, Pos2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
fn app() -> EffectcraftApp {
@@ -580,7 +581,7 @@ fn type_tool_selected_text_preference_skips_ineligible_layers_and_falls_back_to_
}
/// Opens a fresh Type-tool edit holding `text` and returns the harness with the new layer id,
/// so the Deselect All tests start from the same state.
/// so the Deselect All and Duplicate tests start from the same state.
fn type_tool_edit(a: EffectcraftApp, text: &str) -> (Harness<'static, EffectcraftApp>, u64) {
let mut h = harness(a);
h.run_steps(3);
@@ -728,6 +729,110 @@ fn deselect_all_menu_item_follows_the_edit_while_the_project_panel_is_focused()
assert!(!enabled(&h), "Deselect All follows the empty Project selection once the edit is over");
}
/// Layer ids of the active comp, bottom to top.
fn layer_ids(h: &Harness<'_, EffectcraftApp>) -> Vec<u64> {
h.state().session.active_comp().map_or(vec![], |c| c.layers.iter().map(|l| l.id.0).collect())
}
#[test]
fn duplicate_chord_ends_a_type_tool_edit_and_selects_the_copy() {
let (mut h, lid) = type_tool_edit(app(), "Hello");
key(&mut h, Key::D, Modifiers::COMMAND);
let s = &h.state().session;
assert!(s.state.text_edit.is_none(), "Duplicate ends the open edit, as Deselect All does");
let copy = *s.state.selected_layers.first().expect("the copy is selected");
assert_ne!(copy.0, lid, "the copy (not the edited original) holds the selection");
assert_eq!(layer_doc(s, LayerId(lid)).unwrap().text, "Hello", "the original keeps its text");
assert_eq!(layer_doc(s, copy).unwrap().text, "Hello", "the copy carries it too");
}
#[test]
fn duplicate_chord_ends_an_edit_started_while_the_project_panel_is_focused() {
let (mut h, lid) = project_focused_text_edit("Hi");
key(&mut h, Key::D, Modifiers::COMMAND);
let s = &h.state().session;
assert!(s.state.text_edit.is_none(), "Cmd+D ends the edit even with Project focused");
let copy = s.state.selected_layers.first().expect("the copy is selected");
assert_eq!(layer_doc(s, LayerId(lid)).unwrap().text, "Hi", "the typed text is kept");
assert_eq!(layer_doc(s, *copy).unwrap().text, "Hi", "on the copy as well");
}
#[test]
fn rebound_duplicate_chord_forwards_from_inside_the_edit() {
let mut a = app();
a.session.execute("shortcuts.set", json!({"command": "edit.duplicate", "keys": ["Cmd+Alt+D"]})).unwrap();
let (mut h, lid) = type_tool_edit(a, "Hello");
// The default key no longer belongs to Duplicate: while the editor holds the keyboard a
// chord that matches no command does nothing, and the current binding is what forwards.
let n = layer_ids(&h).len();
key(&mut h, Key::D, Modifiers::COMMAND);
assert!(h.state().session.state.text_edit.is_some(), "Cmd+D was rebound away: the edit stays open");
assert_eq!(layer_ids(&h).len(), n, "and nothing was duplicated");
key(&mut h, Key::D, Modifiers { alt: true, ..Modifiers::COMMAND });
let s = &h.state().session;
assert!(s.state.text_edit.is_none(), "the rebound chord ends the edit");
assert_eq!(layer_doc(s, LayerId(lid)).unwrap().text, "Hello", "the text is kept");
assert_eq!(layer_ids(&h).len(), n + 1, "and the copy exists");
}
#[test]
fn duplicate_rebound_onto_a_bare_letter_stays_typing() {
let mut a = app();
a.session.execute("shortcuts.set", json!({"command": "edit.duplicate", "keys": ["D"]})).unwrap();
let (mut h, lid) = type_tool_edit(a, "Hello");
let n = layer_ids(&h).len();
// A binding without a command modifier is text as far as the editor is concerned: the editor
// must not surrender a key that types (same invariant as the app-wide dispatcher).
key(&mut h, Key::D, Modifiers::NONE);
assert!(h.state().session.state.text_edit.is_some(), "a bare-letter Duplicate binding does not end the edit");
assert_eq!(layer_ids(&h).len(), n, "and does not duplicate");
type_text(&mut h, "D");
let (lid2, text, _, _) = edited(&h);
assert_eq!(lid2, lid, "the same edit on the same layer continues");
assert_eq!(text, "HelloD", "and the letter typed into the text");
}
#[test]
fn duplicate_chord_copies_the_edited_layer_not_an_effect_still_selected() {
let mut a = app();
let lid = a.session.execute("layer.newText", json!({"text": "alpha beta", "size": 40, "box": [40, 60, 250, 100]})).unwrap()["layer"].as_u64().unwrap();
// Applying the blur selects it, as clicking it in Effect Controls does.
a.session.execute("effect.apply", json!({"layers": [lid], "effect": "Gaussian Blur"})).unwrap();
a.ui.tool = effectcraft_ui_egui::state::Tool::Type;
let mut h = harness(a);
h.run_steps(3);
let comp = rect(&h, "viewer.comp");
click(&mut h, comp.min + egui::vec2(80.0, 85.0) * (comp.width() / 640.0), Modifiers::NONE);
assert_eq!(edited(&h).0, lid, "the Type tool's click opens the text layer for editing");
assert!(!h.state().session.state.selected_props.is_empty(), "and the blur stays selected");
let n = layer_ids(&h).len();
key(&mut h, Key::D, Modifiers::COMMAND);
let s = &h.state().session;
assert!(s.state.text_edit.is_none(), "Duplicate ends the edit");
assert_eq!(layer_ids(&h).len(), n + 1, "and copies the edited layer");
let blurs = s.active_comp().unwrap().layer(LayerId(lid)).unwrap().effects().map_or(0, |fx| fx.groups().count());
assert_eq!(blurs, 1, "not the blur");
}
#[test]
fn a_chord_bound_to_both_forwarded_commands_resolves_in_list_order() {
let mut a = app();
// Shortcuts may hold one chord for several commands (the conflict is a toast, not a
// refusal), so the editor's forwarding resolves a shared chord in list order — Deselect
// All before Duplicate. This pins that documented contract: the shared chord must end the
// edit like Deselect All, without running Duplicate behind it.
a.session.execute("shortcuts.set", json!({"command": "edit.deselectAll", "keys": ["Cmd+Alt+G"]})).unwrap();
a.session.execute("shortcuts.set", json!({"command": "edit.duplicate", "keys": ["Cmd+Alt+G"]})).unwrap();
let (mut h, lid) = type_tool_edit(a, "Hello");
let n = layer_ids(&h).len();
key(&mut h, Key::G, Modifiers { alt: true, ..Modifiers::COMMAND });
let s = &h.state().session;
assert!(s.state.text_edit.is_none(), "the shared chord still ends the edit");
assert!(s.state.selected_layers.is_empty(), "Deselect All wins the shared chord: nothing is selected");
assert_eq!(layer_ids(&h).len(), n, "so the chord did not duplicate");
assert_eq!(layer_doc(s, LayerId(lid)).unwrap().text, "Hello", "and the text is kept");
}
/// The on-canvas editor's outgoing IME producer: the descriptor carries the edited layer's
/// committed text, the real owner, a stable generation and an explicit change cause. A normal
/// typed character is application work; an accepted input-method commit is not.
@@ -1750,3 +1855,19 @@ fn character_panel_line_join_buttons_set_the_join() {
click(&mut h, bevel, Modifiers::NONE);
assert_eq!(join(&h), effectcraft_engine::keyframe::LineJoin::Bevel);
}
/// #2075: the Character panel's stroke width has a presets menu (0.25 … 100 px) beside its value.
#[test]
fn character_panel_stroke_width_presets_set_the_width() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap();
let lid = s.execute("layer.newText", json!({"text": "Stroke", "size": 80, "applyStroke": true, "strokeWidth": 8})).unwrap()["layer"].as_u64().unwrap();
let mut h = harness(EffectcraftApp::new(s));
h.state_mut().show_panel(PanelKind::Character);
h.run_steps(3);
let arrow = rect(&h, "character.strokeWidth.presets").center();
click(&mut h, arrow, Modifiers::NONE);
let entry = h.get_by_label("0.5 px").rect().center();
click(&mut h, entry, Modifiers::NONE);
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().stroke_width, 0.5);
}
@@ -0,0 +1,166 @@
//! Audio Levels' channel link in the Timeline (#2485): off by default; linked, an edit to one
//! channel moves the other by the same dB, so the stereo balance stays, typed or scrubbed, on a
//! static or animated property, and one Undo takes it back.
use effectcraft_engine::Session;
use effectcraft_engine::project::LayerId;
use effectcraft_ui_egui::EffectcraftApp;
use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2, vec2};
use egui_kittest::Harness;
use serde_json::json;
fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)
}
/// Press on `from`, move to `to` in `steps`, release there.
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, steps: u32) {
h.input_mut().events.push(Event::PointerMoved(from));
h.step();
h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
for k in 1..=steps {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / steps as f32)));
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
let p = center(h, id);
drag(h, p, p, 1);
}
fn type_value(h: &mut Harness<'_, EffectcraftApp>, id: &str, v: &str) {
click(h, id);
h.input_mut().events.push(Event::Text(v.into()));
h.step();
h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
/// A precomp layer (which has Audio Levels) in comp Main at `levels`; its layer id and the
/// property's uid.
fn add_sound_layer(s: &mut Session, levels: [f64; 2]) -> (u64, u64) {
let l = s.execute("layer.addItem", json!({"item": "Sound"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": l, "path": "audio/levels", "value": levels})).unwrap();
(l, s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("audio/levels").unwrap().uid)
}
/// Comps Sound and Main.
fn comps(s: &mut Session) {
s.execute("comp.new", json!({"name": "Sound", "width": 64, "height": 36, "duration": 4})).unwrap();
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap();
}
/// Twirl layer `l` open to its property `uid`.
fn reveal(h: &mut Harness<'_, EffectcraftApp>, l: u64, uid: u64) {
h.state_mut().ui.timeline.open_layers.insert(l);
h.state_mut().ui.timeline.layer_reveal.insert(l, vec!["props".into()]);
h.state_mut().ui.timeline.reveal_props.insert(uid);
h.run_steps(3);
}
fn levels_of(h: &Harness<'_, EffectcraftApp>, l: u64) -> Vec<f64> {
h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("audio/levels").unwrap().value.components()
}
#[test]
fn linked_audio_levels_move_both_channels_by_the_same_db() {
let mut s = Session::default();
comps(&mut s);
let (l, uid) = add_sound_layer(&mut s, [-6.0, -12.0]);
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
reveal(&mut h, l, uid);
let levels =
|h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("audio/levels").unwrap().clone();
let set = |h: &mut Harness<'_, EffectcraftApp>, v: [f64; 2]| {
h.state_mut().session.execute("prop.set", json!({"layer": l, "path": "audio/levels", "value": v})).unwrap();
h.run_steps(2);
};
let undo = |h: &mut Harness<'_, EffectcraftApp>| {
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
};
let (left, right, link) = (format!("timeline.prop.{uid}.value.0"), format!("timeline.prop.{uid}.value.1"), format!("timeline.prop.{uid}.link"));
assert_eq!(h.state().auto.find(&link).unwrap().label, "Link Channels");
type_value(&mut h, &left, "-3");
assert_eq!(levels(&h).value.components(), vec![-3.0, -12.0], "unlinked by default, one channel moves alone");
set(&mut h, [-6.0, -12.0]);
click(&mut h, &link);
type_value(&mut h, &left, "-3");
assert_eq!(levels(&h).value.components(), vec![-3.0, -9.0], "linked, both rise 3 dB and keep their 6 dB balance");
undo(&mut h);
assert_eq!(levels(&h).value.components(), vec![-6.0, -12.0], "one Undo takes back both channels");
type_value(&mut h, &right, "0");
assert_eq!(levels(&h).value.components(), vec![6.0, 0.0], "the right channel leads just the same");
set(&mut h, [0.0, 0.0]);
type_value(&mut h, &left, "-6");
assert_eq!(levels(&h).value.components(), vec![-6.0, -6.0], "equal channels at 0 dB stay equal");
set(&mut h, [-6.0, -12.0]);
let from = center(&h, &left);
drag(&mut h, from, from + vec2(30.0, 0.0), 6);
let c = levels(&h).value.components();
assert!(c[0] > -6.0, "the scrub raised the left channel: {c:?}");
assert!((c[0] - c[1] - 6.0).abs() < 1e-9, "a linked scrub keeps the balance: {c:?}");
undo(&mut h);
assert_eq!(levels(&h).value.components(), vec![-6.0, -12.0], "one Undo takes back the whole scrub");
// Animated: the edit at the current time changes that key's two channels together.
h.state_mut().session.execute("prop.addKey", json!({"layer": l, "path": "audio/levels", "time": 0.0, "value": [-6.0, -12.0]})).unwrap();
h.state_mut().session.execute("prop.addKey", json!({"layer": l, "path": "audio/levels", "time": 1.0, "value": [0.0, 0.0]})).unwrap();
h.run_steps(2);
type_value(&mut h, &left, "-10");
let keys: Vec<Vec<f64>> = levels(&h).keys.iter().map(|k| k.value.components()).collect();
assert_eq!(keys, vec![vec![-10.0, -16.0], vec![0.0, 0.0]], "the key at the current time moves both channels");
click(&mut h, &link);
type_value(&mut h, &left, "-4");
let keys: Vec<Vec<f64>> = levels(&h).keys.iter().map(|k| k.value.components()).collect();
assert_eq!(keys[0], vec![-4.0, -16.0], "unlinked again, the right channel stays");
}
/// With several layers selected, a linked typed Left sets each layer's Left and moves its Right
/// by that layer's own change: every layer keeps its own balance.
#[test]
fn linked_typing_keeps_each_selected_layers_own_balance() {
let mut s = Session::default();
comps(&mut s);
let (a, uid) = add_sound_layer(&mut s, [-6.0, -12.0]);
let (b, other) = add_sound_layer(&mut s, [-20.0, -30.0]);
s.execute("layer.select", json!({"layers": [a, b]})).unwrap();
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
reveal(&mut h, a, uid);
reveal(&mut h, b, other);
click(&mut h, &format!("timeline.prop.{uid}.link"));
type_value(&mut h, &format!("timeline.prop.{uid}.value.0"), "-3");
assert_eq!(levels_of(&h, a), vec![-3.0, -9.0]);
assert_eq!(levels_of(&h, b), vec![-3.0, -13.0], "B rises 17 dB on both channels, keeping its 10 dB balance");
}
/// A link turned on does not carry into a new project whose Audio Levels reuse the property uid.
#[test]
fn a_new_project_starts_with_its_audio_levels_unlinked() {
let mut s = Session::default();
comps(&mut s);
let (l, uid) = add_sound_layer(&mut s, [0.0, 0.0]);
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
reveal(&mut h, l, uid);
click(&mut h, &format!("timeline.prop.{uid}.link"));
assert!(h.state().ui.timeline.is_linked(uid, false));
h.state_mut().session.execute("file.newProject", json!({})).unwrap();
comps(&mut h.state_mut().session);
let (l, again) = add_sound_layer(&mut h.state_mut().session, [0.0, 0.0]);
assert_eq!(again, uid, "the new project reuses the property uid");
reveal(&mut h, l, again);
assert!(!h.state().ui.timeline.is_linked(again, false), "the new project's levels start unlinked");
type_value(&mut h, &format!("timeline.prop.{again}.value.0"), "-3");
assert_eq!(levels_of(&h, l), vec![-3.0, 0.0]);
}
@@ -0,0 +1,83 @@
//! Shift-clicking a group row in the Timeline adds it to the selection, as on a property row, so
//! several Puppet pins can be selected and deleted together (#1751).
use effectcraft_engine::Session;
use effectcraft_engine::project::{LayerId, PropGroup};
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::dock::PanelKind;
use egui::{Modifiers, Pos2, vec2};
use egui_kittest::Harness;
use serde_json::json;
fn harness() -> (Harness<'static, EffectcraftApp>, u64, [u64; 3]) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 200, "height": 100, "frameRate": 30, "duration": 2})).unwrap();
let layer = s.execute("layer.newSolid", json!({"width": 100, "height": 60, "color": [0, 0, 1]})).unwrap()["layer"].as_u64().unwrap();
let pin = |s: &mut Session, x: i64| s.execute("puppet.addPin", json!({"layer": layer, "position": [x, 30]})).unwrap()["pin"].as_u64().unwrap();
let pins = [pin(&mut s, 10), pin(&mut s, 50), pin(&mut s, 90)];
let mut app = EffectcraftApp::new(s);
app.show_panel(PanelKind::Timeline);
app.ui.timeline.open_layers.insert(layer);
// Twirl open every group down to the pins.
fn all(g: &PropGroup, out: &mut Vec<u64>) {
for c in g.groups() {
out.push(c.uid);
all(c, out);
}
}
let mut open = vec![];
if let Some(l) = app.session.active_comp().and_then(|c| c.layer(LayerId(layer))) {
all(&l.props, &mut open);
}
app.ui.timeline.open_groups.extend(open);
let mut h = Harness::builder().with_size(vec2(1600.0, 1100.0)).build_eframe(|_| app);
h.run_steps(4);
(h, layer, pins)
}
fn row(h: &Harness<'_, EffectcraftApp>, uid: u64) -> Pos2 {
let id = format!("timeline.group.{uid}.name");
let e = h.state().auto.find(&id).unwrap_or_else(|| panic!("no {id}"));
egui::pos2(e.rect[0] + e.rect[2].min(40.0) / 2.0, e.rect[1] + e.rect[3] / 2.0)
}
fn click(h: &mut Harness<'_, EffectcraftApp>, uid: u64, modifiers: Modifiers) {
let at = row(h, uid);
h.input_mut().events.push(egui::Event::ModifiersChanged(modifiers));
h.input_mut().events.push(egui::Event::PointerMoved(at));
h.input_mut().events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers });
h.input_mut().events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers });
h.run_steps(3);
h.input_mut().events.push(egui::Event::ModifiersChanged(Modifiers::NONE));
h.step();
}
fn selected(h: &Harness<'_, EffectcraftApp>, pins: [u64; 3]) -> Vec<u64> {
h.state().session.state.selected_props.iter().map(|(_, u)| *u).filter(|u| pins.contains(u)).collect()
}
fn pin_count(h: &Harness<'_, EffectcraftApp>, layer: u64) -> usize {
let l = h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap();
l.effects().unwrap().groups().map(effectcraft_engine::effects::puppet::pin_count).sum()
}
#[test]
fn shift_click_adds_puppet_pins_and_delete_removes_them_together() {
let (mut h, layer, pins) = harness();
let [a, b, c] = pins;
click(&mut h, a, Modifiers::NONE);
assert_eq!(selected(&h, pins), [a]);
click(&mut h, b, Modifiers::SHIFT);
assert_eq!(selected(&h, pins), [a, b], "Shift-click keeps the first pin");
click(&mut h, c, Modifiers::NONE);
assert_eq!(selected(&h, pins), [c], "a plain click selects only the clicked pin");
click(&mut h, a, Modifiers::NONE);
click(&mut h, b, Modifiers::SHIFT);
h.state_mut().session.execute("edit.clear", json!({})).unwrap();
h.run_steps(3);
assert_eq!(pin_count(&h, layer), 1, "Delete removes both selected pins");
assert!(h.state().session.active_comp().unwrap().layer(LayerId(layer)).is_some(), "the layer stays");
h.state_mut().session.undo();
assert_eq!(pin_count(&h, layer), 3, "one Undo restores both pins");
}

Some files were not shown because too many files have changed in this diff Show More