diff --git a/Cargo.lock b/Cargo.lock index e55969e6f..07564b7df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/Cargo.toml b/Cargo.toml index 24791c08d..afc59257b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index bdbdcda94..a560b00e3 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -155,7 +155,7 @@ fn install_file_dialogs(app: &mut EffectcraftApp) -> Option 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::>()) { + if let Some(result) = craft_update::self_update::finish(&effectcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) { std::process::exit(i32::from(result.is_err())); } // First, so the panic hook and every start-up warning are recorded. Records go to stderr diff --git a/contributors/people.toml b/contributors/people.toml index 59365207a..e0915f09c 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -114,3 +114,6 @@ sync_github_name = false [people.ashokram-si] real_name = "Ashok Ram" + +[people.dylanmtaylor] +sync_github_name = true diff --git a/crates/automation/src/backend.rs b/crates/automation/src/backend.rs index 8f7931239..5bba7172a 100644 --- a/crates/automation/src/backend.rs +++ b/crates/automation/src/backend.rs @@ -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 { let params = if params.is_null() { json!({}) } else { params }; if let Some(c) = self.confinement.clone() { - let lookup = match c.check_command(id, ¶ms)? { - Some(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, ¶ms)? { + let found = match lookup { + Lookup::ProjectFile | Lookup::QueueDefault => self.run("project.summary", json!({}))?, + Lookup::RenderOutputs => self.run("renderQueue.list", json!({}))?, + }; c.check_lookup(&lookup, &found)?; } } diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index 9b126bdcb..0e9d79d4b 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -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> { + pub fn check_command(&self, id: &str, params: &Value) -> Result> { 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 => { diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 68376fa46..ff7eedef8 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -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()); } diff --git a/crates/effects/src/matte.rs b/crates/effects/src/matte.rs index 4c42c9c39..d695a3b41 100644 --- a/crates/effects/src/matte.rs +++ b/crates/effects/src/matte.rs @@ -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 = [-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(); diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 8442ad74e..1168c431b 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -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}; diff --git a/crates/engine/src/cmd/anim.rs b/crates/engine/src/cmd/anim.rs index e711392e0..e23fc736f 100644 --- a/crates/engine/src/cmd/anim.rs +++ b/crates/engine/src/cmd/anim.rs @@ -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 { + 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 { let clip = s.state.key_clipboard.clone(); @@ -90,6 +94,9 @@ fn paste(s: &mut Session, p: &Value) -> Result { 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 { } } 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 = 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 { } 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 = (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 = 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 { 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 { 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 { } 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, Vec) = 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 })); diff --git a/crates/engine/src/cmd/anim_tools.rs b/crates/engine/src/cmd/anim_tools.rs index 06e0d341b..33ee39ae2 100644 --- a/crates/engine/src/cmd/anim_tools.rs +++ b/crates/engine/src/cmd/anim_tools.rs @@ -213,7 +213,8 @@ fn sample_curve( fn grouped_selection(s: &Session) -> std::collections::BTreeMap<(LayerId, Uid), Vec> { let mut g: std::collections::BTreeMap<(LayerId, Uid), Vec> = 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(); diff --git a/crates/engine/src/cmd/animation.rs b/crates/engine/src/cmd/animation.rs index 58eaa060d..4808d7538 100644 --- a/crates/engine/src/cmd/animation.rs +++ b/crates/engine/src/cmd/animation.rs @@ -271,7 +271,8 @@ fn add_keyframe(s: &mut Session, _: &Value) -> Result { /// Selected keys grouped by (layer, property). fn keys_by_prop(s: &Session) -> BTreeMap<(LayerId, Uid), Vec> { let mut m: BTreeMap<(LayerId, Uid), Vec> = 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 diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index dd1685722..b33f4cc5c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -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 { - // 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 { 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 { } 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::>())) } @@ -968,7 +987,15 @@ pub fn specs() -> Vec { 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(()) } diff --git a/crates/engine/src/cmd/key_transform.rs b/crates/engine/src/cmd/key_transform.rs index b78439266..397db4de5 100644 --- a/crates/engine/src/cmd/key_transform.rs +++ b/crates/engine/src/cmd/key_transform.rs @@ -72,6 +72,10 @@ fn transform(s: &mut Session, p: &Value) -> Result { 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 { 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")); diff --git a/crates/engine/src/cmd/keys_more.rs b/crates/engine/src/cmd/keys_more.rs index c7785d08c..4edef885a 100644 --- a/crates/engine/src/cmd/keys_more.rs +++ b/crates/engine/src/cmd/keys_more.rs @@ -37,6 +37,8 @@ pub(crate) fn label_keys(s: &mut Session, p: &Value) -> Result { 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 { diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index eb9641547..97fb0f160 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -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 { 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); diff --git a/crates/engine/src/cmd/layer/select.rs b/crates/engine/src/cmd/layer/select.rs index 0edf7a72c..efe038a0a 100644 --- a/crates/engine/src/cmd/layer/select.rs +++ b/crates/engine/src/cmd/layer/select.rs @@ -25,15 +25,21 @@ pub(super) fn select(s: &mut Session, p: &Value) -> Result { } } } 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::>())) } +/// 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) { + 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 { let comp = s.active_comp().ok_or(EngineError::NoComp)?; if comp.layers.is_empty() { diff --git a/crates/engine/src/cmd/layer/timing.rs b/crates/engine/src/cmd/layer/timing.rs index 0427dcf0d..650544d17 100644 --- a/crates/engine/src/cmd/layer/timing.rs +++ b/crates/engine/src/cmd/layer/timing.rs @@ -84,8 +84,9 @@ pub(super) fn move_frames(s: &mut Session, p: &Value, frames: i64) -> Result Result { 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 { } Ok(()) })?; - Ok(json!({"slipped": shifts.iter().map(|(id, d)| json!({"layer": id.0, "delta": d.seconds()})).collect::>()})) + Ok(json!({ + "requested": delta.seconds(), + "slipped": shifts.iter().map(|(id, d)| json!({"layer": id.0, "delta": d.seconds(), "clamped": *d != delta})).collect::>(), + })) } pub(super) fn arrange(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/mask_interp.rs b/crates/engine/src/cmd/mask_interp.rs index e2b53f8a4..ed7ed5190 100644 --- a/crates/engine/src/cmd/mask_interp.rs +++ b/crates/engine/src/cmd/mask_interp.rs @@ -438,20 +438,22 @@ fn interpolate(s: &mut Session, p: &Value) -> Result { // 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 { let ts: Vec = 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(); diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index ff4d443ea..2929d3347 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -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)> { + 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)> { 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)> 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 diff --git a/crates/engine/src/cmd/prop.rs b/crates/engine/src/cmd/prop.rs index 3c9ce42b8..50c261020 100644 --- a/crates/engine/src/cmd/prop.rs +++ b/crates/engine/src/cmd/prop.rs @@ -247,7 +247,8 @@ fn property_reply(p: &Value, layer: &Layer, pr: &Property, time: Tick, value: Va /// property (by match path, `transform/position`, `effects/#n/`) 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> { if b_p(p, "selected") != Some(true) { return Ok(vec![]); @@ -260,14 +261,31 @@ fn selected_twins(s: &Session, p: &Value, cmd: &str) -> Result = 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> { + 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) -> Option> { 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 { (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 = + 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 { 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 { + 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, usize, &mut Tick) -> bool) -> Result { 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 { 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 { } } } - 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 { "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 ), diff --git a/crates/engine/src/cmd/timeline.rs b/crates/engine/src/cmd/timeline.rs index 633e73c5a..ae57a3b88 100644 --- a/crates/engine/src/cmd/timeline.rs +++ b/crates/engine/src/cmd/timeline.rs @@ -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> { match str_p(p, "format") { @@ -95,18 +97,34 @@ fn isolate(project: &Project, cid: ItemId, lid: LayerId) -> Option { 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 { - 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>, + footage: Arc, + expr: Option>, + accel: Option>, + 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 { + 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 { 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, 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 { "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 { "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 ), diff --git a/crates/engine/src/settings/prefs.rs b/crates/engine/src/settings/prefs.rs index d2db7cd55..94c90c3a3 100644 --- a/crates/engine/src/settings/prefs.rs +++ b/crates/engine/src/settings/prefs.rs @@ -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"; diff --git a/crates/engine/src/settings/prefs/document.rs b/crates/engine/src/settings/prefs/document.rs index d82205352..1f68c4a06 100644 --- a/crates/engine/src/settings/prefs/document.rs +++ b/crates/engine/src/settings/prefs/document.rs @@ -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) diff --git a/crates/engine/src/tests/anim_tools.rs b/crates/engine/src/tests/anim_tools.rs index 06d194001..96b861972 100644 --- a/crates/engine/src/tests/anim_tools.rs +++ b/crates/engine/src/tests/anim_tools.rs @@ -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(); diff --git a/crates/engine/src/tests/delete_keys.rs b/crates/engine/src/tests/delete_keys.rs index b1b9af316..ec6ec888e 100644 --- a/crates/engine/src/tests/delete_keys.rs +++ b/crates/engine/src/tests/delete_keys.rs @@ -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::>(), [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"); +} diff --git a/crates/engine/src/tests/fidelity.rs b/crates/engine/src/tests/fidelity.rs index ebe375601..4fd8a68b8 100644 --- a/crates/engine/src/tests/fidelity.rs +++ b/crates/engine/src/tests/fidelity.rs @@ -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); diff --git a/crates/engine/src/tests/mask_warp.rs b/crates/engine/src/tests/mask_warp.rs index adcea44be..5387fe562 100644 --- a/crates/engine/src/tests/mask_warp.rs +++ b/crates/engine/src/tests/mask_warp.rs @@ -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. diff --git a/crates/engine/src/tests/paint.rs b/crates/engine/src/tests/paint.rs index db831ca96..3659ce0c4 100644 --- a/crates/engine/src/tests/paint.rs +++ b/crates/engine/src/tests/paint.rs @@ -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() diff --git a/crates/engine/src/tests/settings.rs b/crates/engine/src/tests/settings.rs index f2fd5a3b7..79e9fa727 100644 --- a/crates/engine/src/tests/settings.rs +++ b/crates/engine/src/tests/settings.rs @@ -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(); diff --git a/crates/engine/src/tests/text_edit.rs b/crates/engine/src/tests/text_edit.rs index 7a04ea155..eb1de6f89 100644 --- a/crates/engine/src/tests/text_edit.rs +++ b/crates/engine/src/tests/text_edit.rs @@ -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::>(); + 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"); } diff --git a/crates/engine/src/tests/three_d.rs b/crates/engine/src/tests/three_d.rs index 748f8a32f..5b5af594d 100644 --- a/crates/engine/src/tests/three_d.rs +++ b/crates/engine/src/tests/three_d.rs @@ -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() } diff --git a/crates/engine/src/tests/timeline.rs b/crates/engine/src/tests/timeline.rs index c912e0d87..55475eef3 100644 --- a/crates/engine/src/tests/timeline.rs +++ b/crates/engine/src/tests/timeline.rs @@ -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> = 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"); +} diff --git a/crates/engine/src/tests/timeline_interchange.rs b/crates/engine/src/tests/timeline_interchange.rs index 45be0f7a1..0ebfa2316 100644 --- a/crates/engine/src/tests/timeline_interchange.rs +++ b/crates/engine/src/tests/timeline_interchange.rs @@ -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(" 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() { diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index d4d7a62a1..fdcb58dd4 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -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(()) } diff --git a/crates/render/src/layers.rs b/crates/render/src/layers.rs index ce0a2dcde..d105c6f13 100644 --- a/crates/render/src/layers.rs +++ b/crates/render/src/layers.rs @@ -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 { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 488f38ec7..3d2134e89 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -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(); diff --git a/crates/text/src/fonts/tests.rs b/crates/text/src/fonts/tests.rs index 1f7e0d46f..d95f07c6a 100644 --- a/crates/text/src/fonts/tests.rs +++ b/crates/text/src/fonts/tests.rs @@ -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); diff --git a/crates/ui-egui/src/a11y.rs b/crates/ui-egui/src/a11y.rs new file mode 100644 index 000000000..43a0a3fe4 --- /dev/null +++ b/crates/ui-egui/src/a11y.rs @@ -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) { + 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) { + 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 +} diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 9977af810..ffbf57257 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -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 { - 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); - } -} diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 483dcf4bf..b66267d4d 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -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) { diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 8710f1f9a..0e6ab3d2b 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -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"); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 7f4a77989..9eed465e4 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -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", "出力をシミュレート"), diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index ec70e3197..ed4378271 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -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", "ეფექტებისა და პრესეტების პარამეტრები"), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 385a6b1ca..20a62c68a 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -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)]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 27e24d4da..ad1842e3d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/menus/entries.rs b/crates/ui-egui/src/menus/entries.rs index d7b5d56ca..3391e47bd 100644 --- a/crates/ui-egui/src/menus/entries.rs +++ b/crates/ui-egui/src/menus/entries.rs @@ -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; } diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 23065067f..6ee3e2bb9 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -115,7 +115,7 @@ fn reveal_targets(app: &EffectcraftApp) -> Vec { /// 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 { @@ -131,7 +131,10 @@ fn nudge(app: &mut EffectcraftApp, ctx: &egui::Context, dir: &str) -> Result 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(¶ms) && app.ui.focused == PanelKind::RenderQueue { + app.apply_dock(craft_ui::docking::Action::Close { panel: PanelKind::RenderQueue })?; + // The focus goes to a panel that is still there, so the next Close acts on the composition + // instead of on the queue that is gone. + let next = [PanelKind::Timeline, PanelKind::Composition, PanelKind::Project].into_iter().find(|p| panel_shown(app, *p)).unwrap_or(PanelKind::Project); + app.show_panel(next); + app.focus_panel(ctx, next); + // Activating a passive viewer emits OpenComp, which raises Timeline. Finish that routing + // before restoring the destination panel, including when they share the same tab group. + app.handle_events(ctx); + app.show_panel(next); + return Ok(Value::Null); + } // Edit ▸ Paste with SVG markup on the system clipboard pastes it as a shape layer (After // Effects 26.3). Copying in the app replaces the system clipboard with a note naming what // was copied, so SVG there was copied after anything copied in the app. diff --git a/crates/ui-egui/src/menus/shortcuts.rs b/crates/ui-egui/src/menus/shortcuts.rs index f50fbdb3c..92e4d5d2e 100644 --- a/crates/ui-egui/src/menus/shortcuts.rs +++ b/crates/ui-egui/src/menus/shortcuts.rs @@ -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 diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index ed2fcf6ca..36d667a72 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -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")); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index f6a5b5321..c4a6f4d3a 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -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 { +/// 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) -> Vec { 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 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, diff --git a/crates/ui-egui/src/panels/anim_tools.rs b/crates/ui-egui/src/panels/anim_tools.rs index e6b62efb4..67ae71c0b 100644 --- a/crates/ui-egui/src/panels/anim_tools.rs +++ b/crates/ui-egui/src/panels/anim_tools.rs @@ -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 } diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index 01fbc5995..7e49d8a7d 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -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()); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index a0c4b7651..ac26bd0c1 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -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 { diff --git a/crates/ui-egui/src/panels/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs index 9b3535288..16137b599 100644 --- a/crates/ui-egui/src/panels/dialogs_3d.rs +++ b/crates/ui-egui/src/panels/dialogs_3d.rs @@ -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")); diff --git a/crates/ui-egui/src/panels/effect_controls/rows.rs b/crates/ui-egui/src/panels/effect_controls/rows.rs index 249c8462b..665922fcf 100644 --- a/crates/ui-egui/src/panels/effect_controls/rows.rs +++ b/crates/ui-egui/src/panels/effect_controls/rows.rs @@ -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) { + 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). diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index e140e49e1..1ee34b4b1 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -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, + parent: Option, + /// 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, +} + +/// 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, + /// The open folders on the way down to the row being drawn, by level. + stack: Vec, + cursor: Option, + /// 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) -> 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, @@ -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 { + 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::(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 = 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) -> Arc> { 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 == ©_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(©_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 = None; + let mut nav = Nav { rows: vec![], stack: vec![], cursor: ctx.data(|d| d.get_temp::(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::(&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()), diff --git a/crates/ui-egui/src/panels/essential.rs b/crates/ui-egui/src/panels/essential.rs index a08b96b62..02e077508 100644 --- a/crates/ui-egui/src/panels/essential.rs +++ b/crates/ui-egui/src/panels/essential.rs @@ -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)); } diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 29364275b..65d825b45 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -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(" Result { /// 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 diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 35a745c81..be18999d4 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -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 = 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::(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::(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::(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)); } } diff --git a/crates/ui-egui/src/panels/key_dialogs.rs b/crates/ui-egui/src/panels/key_dialogs.rs index 22e31c468..2a5579837 100644 --- a/crates/ui-egui/src/panels/key_dialogs.rs +++ b/crates/ui-egui/src/panels/key_dialogs.rs @@ -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(); diff --git a/crates/ui-egui/src/panels/layer_styles_dialog.rs b/crates/ui-egui/src/panels/layer_styles_dialog.rs index b646b002e..09b775e30 100644 --- a/crates/ui-egui/src/panels/layer_styles_dialog.rs +++ b/crates/ui-egui/src/panels/layer_styles_dialog.rs @@ -23,6 +23,8 @@ pub struct LayerStyleState { pub selected: String, pub undo_mark: usize, checkpoint: Option, + /// 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 { 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 { 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::()).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 { diff --git a/crates/ui-egui/src/panels/markers_ui.rs b/crates/ui-egui/src/panels/markers_ui.rs index 03c45d32b..2c21a293b 100644 --- a/crates/ui-egui/src/panels/markers_ui.rs +++ b/crates/ui-egui/src/panels/markers_ui.rs @@ -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:")); diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index 2fec0e4c2..73a0b3a1c 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -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 diff --git a/crates/ui-egui/src/panels/paint_panels.rs b/crates/ui-egui/src/panels/paint_panels.rs index c867eb63a..edf12ed7a 100644 --- a/crates/ui-egui/src/panels/paint_panels.rs +++ b/crates/ui-egui/src/panels/paint_panels.rs @@ -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"); diff --git a/crates/ui-egui/src/panels/panel_kit.rs b/crates/ui-egui/src/panels/panel_kit.rs index 501ddf64f..c1d88a417 100644 --- a/crates/ui-egui/src/panels/panel_kit.rs +++ b/crates/ui-egui/src/panels/panel_kit.rs @@ -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); diff --git a/crates/ui-egui/src/panels/path_vr_panels.rs b/crates/ui-egui/src/panels/path_vr_panels.rs index 680d9f5a0..fcba902e4 100644 --- a/crates/ui-egui/src/panels/path_vr_panels.rs +++ b/crates/ui-egui/src/panels/path_vr_panels.rs @@ -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); diff --git a/crates/ui-egui/src/panels/precomp.rs b/crates/ui-egui/src/panels/precomp.rs index 7c1750d5e..6ec9072f3 100644 --- a/crates/ui-egui/src/panels/precomp.rs +++ b/crates/ui-egui/src/panels/precomp.rs @@ -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| { diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index cdf57de27..f84a49d84 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -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); } } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index a7563a6ff..4ef57b1ab 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -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})); } diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index 321224c64..21dc828ca 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -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(¬ify_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 = 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 = 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, actions: &mut Vec) { +fn delete_selected(app: &EffectcraftApp, ui: &egui::Ui, allowed: bool, on_row: bool, selected: &mut Option, actions: &mut Vec) { 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, &mut Option), + 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 = 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 = 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); } diff --git a/crates/ui-egui/src/panels/rq_templates.rs b/crates/ui-egui/src/panels/rq_templates.rs index 753dea50d..93a458aae 100644 --- a/crates/ui-egui/src/panels/rq_templates.rs +++ b/crates/ui-egui/src/panels/rq_templates.rs @@ -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(); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 562a90132..816b0ca01 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -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 = 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::>() + }) { + 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(); diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 4a2a8b6cd..0ab0f10ec 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -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) { + 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 = 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) { 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(); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index cb61e6f08..a1522f501 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -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::(&ctx).as_deref() && ui.rect_contains_pointer(rect) { - let items: Vec = crate::panels::project_drop_items(app, *dragged) + let items: Vec = 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, ¶ms, lbl))).clicked() { + if !selected { + actions.push(("keys.select".into(), json!({"keys": [this_key.clone()]}))); + } + actions.push((cmd.into(), params)); + ui.close(); + } + } } /// Starts a key drag (or Alt-scale). @@ -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::>()); + if levels { + a["dims"] = json!([d]); + a["follow"] = json!((from..from + shown).filter(|&e| e != d).collect::>()); + } else { + a["dims"] = json!((from..from + shown).collect::>()); + } } } 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); } diff --git a/crates/ui-egui/src/panels/timeline/tests.rs b/crates/ui-egui/src/panels/timeline/tests.rs index 9a5c89f7d..ebcbfa8b9 100644 --- a/crates/ui-egui/src/panels/timeline/tests.rs +++ b/crates/ui-egui/src/panels/timeline/tests.rs @@ -540,6 +540,94 @@ fn disk_bar_range_covers_partial_frames() { let above_limit = TMap { start: 100_001.0, ..tm }; assert_eq!(disk_bar_range(&above_limit, clip, 1.0, i64::MAX), 100_000..100_000); } + +/// The keyframe menu is one for the Timeline and the Graph Editor: its commands are drawn in the +/// interface language, and a locked layer's keys can be copied and selected but not edited. +#[test] +fn the_key_menu_is_translated_and_cannot_edit_a_locked_layers_keys() { + use egui_kittest::Harness; + use egui_kittest::kittest::{NodeT, Queryable}; + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"name": "C", "width": 64, "height": 64, "duration": 2})).unwrap(); + let id = s.execute("layer.newSolid", json!({"name": "Box", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap(); + for (t, v) in [(0.0, 0), (1.0, 50)] { + s.execute("prop.addKey", json!({"layer": id, "path": "transform/opacity", "time": t, "value": v})).unwrap(); + } + let app = EffectcraftApp::new(s); + let layer = app.session.active_comp().unwrap().layer(LayerId(id)).unwrap().clone(); + let prop = layer.props.prop("transform/opacity").unwrap().clone(); + let key = prop.keys[0].clone(); + let enabled = |locked: bool, selected: bool, label: &str| { + let mut layer = layer.clone(); + layer.switches.locked = locked; + let mut actions = vec![]; + let mut h = Harness::new_ui(|ui| key_menu(ui, &app, &layer, &prop, &key, selected, &mut actions)); + h.run(); + !h.get_by_label(label).accesskit_node().is_disabled() + }; + for label in [ + "Copy", + "Select Equal Keyframes", + "Select All Keyframes", + "Delete", + "Edit Value…", + "Easy Ease", + "Time-Reverse Keyframes", + "Keyframe Velocity…", + "Mute Keyframes", + ] { + assert!(enabled(false, true, label), "{label} on an unlocked layer"); + } + for label in ["Copy", "Select Equal Keyframes", "Select All Keyframes"] { + assert!(enabled(true, true, label), "{label} is still available on a locked layer"); + } + for label in ["Delete", "Edit Value…", "Easy Ease", "Time-Reverse Keyframes", "Keyframe Velocity…", "Mute Keyframes", "Linear"] { + assert!(!enabled(true, true, label), "{label} is off on a locked layer"); + assert!(!enabled(true, false, label), "{label} is off on an unselected locked key"); + } + for label in ["Copy", "Select Equal Keyframes", "Select Previous Keyframes", "Select Following Keyframes"] { + assert!(!enabled(true, false, label), "{label} requires selecting the clicked key"); + } + assert!(enabled(true, false, "Select All Keyframes")); + // The labels follow the interface language. + crate::i18n::set_current("es"); + let copy = crate::i18n::context_item("keys.copy", &json!({}), "Copy"); + assert_ne!(copy, "Copy", "the catalog translates Copy"); + assert!(enabled(false, true, ©)); + crate::i18n::set_current("en"); +} + +#[test] +fn a_key_menu_edit_selects_the_clicked_key_before_applying_the_command() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut s = effectcraft_engine::Session::default(); + s.execute("comp.new", json!({"width": 64, "height": 64, "duration": 2})).unwrap(); + let id = s.execute("layer.newSolid", json!({"color": "#fff"})).unwrap()["layer"].as_u64().unwrap(); + for (time, value) in [(0.0, 0), (1.0, 50)] { + s.execute("prop.addKey", json!({"layer": id, "path": "transform/opacity", "time": time, "value": value})).unwrap(); + } + let layer = s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone(); + let prop = layer.props.prop("transform/opacity").unwrap().clone(); + s.execute("keys.select", json!({"keys": [{"layer": id, "prop": prop.uid, "time": 1.0}]})).unwrap(); + let mut app = EffectcraftApp::new(s); + let mut actions = vec![]; + { + let mut h = Harness::new_ui(|ui| key_menu(ui, &app, &layer, &prop, &prop.keys[0], false, &mut actions)); + h.run(); + h.get_by_label("Easy Ease").click(); + h.run(); + } + assert_eq!(actions.iter().map(|(command, _)| command.as_str()).collect::>(), ["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 = (1..=6).map(|id| Row { layer: LayerId(id), depth: 0, kind: RowKind::Layer }).collect(); diff --git a/crates/ui-egui/src/panels/tracker.rs b/crates/ui-egui/src/panels/tracker.rs index dac9f7d2b..8d50b4860 100644 --- a/crates/ui-egui/src/panels/tracker.rs +++ b/crates/ui-egui/src/panels/tracker.rs @@ -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| { diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index d084dd006..5c4272066 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -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})); } diff --git a/crates/ui-egui/src/panels/viewer/frame.rs b/crates/ui-egui/src/panels/viewer/frame.rs index 65095b399..e0d2a3bc8 100644 --- a/crates/ui-egui/src/panels/viewer/frame.rs +++ b/crates/ui-egui/src/panels/viewer/frame.rs @@ -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::(&ctx).as_deref() && ui.rect_contains_pointer(rect) { - let items: Vec = crate::panels::project_drop_items(app, *dragged) + let items: Vec = crate::panels::project_drop_items(app, *dragged, None) .into_iter() .map(effectcraft_engine::project::ItemId) .filter(|id| { diff --git a/crates/ui-egui/src/panels/viewer/tools.rs b/crates/ui-egui/src/panels/viewer/tools.rs index 8a3fbf0f3..da4245e85 100644 --- a/crates/ui-egui/src/panels/viewer/tools.rs +++ b/crates/ui-egui/src/panels/viewer/tools.rs @@ -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 diff --git a/crates/ui-egui/src/panels/viewer_drop.rs b/crates/ui-egui/src/panels/viewer_drop.rs index ac56d9acb..aadd2c122 100644 --- a/crates/ui-egui/src/panels/viewer_drop.rs +++ b/crates/ui-egui/src/panels/viewer_drop.rs @@ -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![], diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index ab6d3d561..00c07425d 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -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 { + 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 { + 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 { + let mut times: Vec = 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, frames: &mut Vec, ) { - 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 = 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 = pos.keys.iter().map(|k| k.time).collect(); - let Some((lt0, lt1)) = app.session.prefs.motion_path_span(×, 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 { 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() }; diff --git a/crates/ui-egui/src/panels/viewer_text.rs b/crates/ui-egui/src/panels/viewer_text.rs index ce387f6ca..d4a5c8ccf 100644 --- a/crates/ui-egui/src/panels/viewer_text.rs +++ b/crates/ui-egui/src/panels/viewer_text.rs @@ -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!({})); } _ => {} } diff --git a/crates/ui-egui/src/prefs_live.rs b/crates/ui-egui/src/prefs_live.rs index ee222c892..cc283635f 100644 --- a/crates/ui-egui/src/prefs_live.rs +++ b/crates/ui-egui/src/prefs_live.rs @@ -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 }); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index c1320054a..6abe76b2f 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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) { @@ -429,10 +439,13 @@ pub struct TimelineState { /// was turned off. #[serde(default)] pub unlinked: BTreeSet, - /// 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, + /// 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, diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index 0101e16ae..3530a9efb 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -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"))] diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index ac3368b2e..344cee0cd 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -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, + visited: std::collections::HashSet, + /// 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, +} + +/// 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) -> Option { + 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::(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) -> 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) -> 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::(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) -> Option { 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 { .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 { + 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; 5], events: Vec) { + 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) -> Vec { + 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 { + 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() { diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 29456b2a1..180b56232 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -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; diff --git a/crates/ui-egui/tests/it/ui_checkbox_drag.rs b/crates/ui-egui/tests/it/ui_checkbox_drag.rs new file mode 100644 index 000000000..ba65e54dc --- /dev/null +++ b/crates/ui-egui/tests/it/ui_checkbox_drag.rs @@ -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) { + 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"); +} diff --git a/crates/ui-egui/tests/it/ui_drag_drop.rs b/crates/ui-egui/tests/it/ui_drag_drop.rs index 5608dfcf3..42d0273f8 100644 --- a/crates/ui-egui/tests/it/ui_drag_drop.rs +++ b/crates/ui-egui/tests/it/ui_drag_drop.rs @@ -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] diff --git a/crates/ui-egui/tests/it/ui_graph_fit.rs b/crates/ui-egui/tests/it/ui_graph_fit.rs index c161dfef6..d80ae315e 100644 --- a/crates/ui-egui/tests/it/ui_graph_fit.rs +++ b/crates/ui-egui/tests/it/ui_graph_fit.rs @@ -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); +} diff --git a/crates/ui-egui/tests/it/ui_layer_source_picker.rs b/crates/ui-egui/tests/it/ui_layer_source_picker.rs index ca10e4249..2daa450c4 100644 --- a/crates/ui-egui/tests/it/ui_layer_source_picker.rs +++ b/crates/ui-egui/tests/it/ui_layer_source_picker.rs @@ -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 diff --git a/crates/ui-egui/tests/it/ui_self_drop.rs b/crates/ui-egui/tests/it/ui_self_drop.rs new file mode 100644 index 000000000..a371cc80b --- /dev/null +++ b/crates/ui-egui/tests/it/ui_self_drop.rs @@ -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 { + 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); +} diff --git a/crates/ui-egui/tests/it/ui_tail.rs b/crates/ui-egui/tests/it/ui_tail.rs index bd87a01a7..a824e065c 100644 --- a/crates/ui-egui/tests/it/ui_tail.rs +++ b/crates/ui-egui/tests/it/ui_tail.rs @@ -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, diff --git a/crates/ui-egui/tests/it/ui_text_edit.rs b/crates/ui-egui/tests/it/ui_text_edit.rs index ea4b56d26..6b68028de 100644 --- a/crates/ui-egui/tests/it/ui_text_edit.rs +++ b/crates/ui-egui/tests/it/ui_text_edit.rs @@ -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 { + 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); +} diff --git a/crates/ui-egui/tests/it/ui_timeline_audio_levels.rs b/crates/ui-egui/tests/it/ui_timeline_audio_levels.rs new file mode 100644 index 000000000..bc0b9a7f9 --- /dev/null +++ b/crates/ui-egui/tests/it/ui_timeline_audio_levels.rs @@ -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 { + 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> = 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> = 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]); +} diff --git a/crates/ui-egui/tests/it/ui_timeline_group_select.rs b/crates/ui-egui/tests/it/ui_timeline_group_select.rs new file mode 100644 index 000000000..0a553976b --- /dev/null +++ b/crates/ui-egui/tests/it/ui_timeline_group_select.rs @@ -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) { + 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 { + 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"); +} diff --git a/crates/ui-egui/tests/it/ui_timeline_shapes.rs b/crates/ui-egui/tests/it/ui_timeline_shapes.rs index 2f05e1b99..c98b4af19 100644 --- a/crates/ui-egui/tests/it/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/it/ui_timeline_shapes.rs @@ -503,8 +503,10 @@ fn text_animate_menu_adds_an_animator_with_the_property() { let mut h = harness(s); h.state_mut().ui.timeline.open_groups.insert(text); open_layer(&mut h, l); + // The Properties panel's Position row has the same accessible name: take the menu's new entry. + let shown: Vec<_> = h.query_all_by_label("Position").map(|n| n.rect()).collect(); click(&mut h, &format!("timeline.group.{text}.animate")); - let entry = h.get_by_label("Position").rect(); + let entry = h.query_all_by_label("Position").map(|n| n.rect()).find(|r| !shown.contains(r)).expect("the Animate menu's Position entry"); click_at(&mut h, entry.center()); let animators = |h: &Harness<'_, EffectcraftApp>| { h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("text").unwrap().sub("animators").cloned() diff --git a/crates/ui-egui/tests/ui_accessibility_audit.rs b/crates/ui-egui/tests/ui_accessibility_audit.rs new file mode 100644 index 000000000..8f2f9cc4b --- /dev/null +++ b/crates/ui-egui/tests/ui_accessibility_audit.rs @@ -0,0 +1,396 @@ +//! Accessibility audit (docs/gaps.md gap 25): renders panels headless and walks the AccessKit +//! tree of the frame. Every interactive node (button, check box, text input, combo box, slider, +//! spin button, tree or list item, tab, link, ...) must have a non-empty name, a role that says +//! what it is, and the state that role needs (checked, selected, a value). +//! +//! * The panels that had their pass (`FIXED`: Effects & Presets, Settings, Render Queue with its +//! Templates dialog) must have **zero** findings. +//! * Every other panel is counted and printed (run with `--nocapture`), and checked against +//! [`BASELINE`], a ratchet that can only shrink: fixing a panel lowers its number, and a number +//! must never go up. When a panel's count falls, the test fails until its entry is lowered. +//! +//! What this does not measure: how a real screen reader speaks the tree (none was run), colour +//! contrast, focus order or keyboard operation (the `ui_*_keyboard.rs` tests cover those), or +//! widgets that are scrolled out of view and not drawn. + +use std::collections::HashSet; + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui::accesskit::{Action, Role, Toggled}; +use egui::{Rect, pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::NodeT; +use serde_json::json; + +/// Groups that must be clean. A group is the first part of a scenario name (before the `:`). +const FIXED: &[&str] = &["EffectsPresets", "Settings", "RenderQueue", "Project"]; + +/// Scenarios whose content is the machine's own folder listing (the Media Browser opens on the +/// home folder), so a runner with fewer files finds fewer controls. They are held to the upper bound +/// only: the "improved, lower the entry" check would fail on any machine with a different home. +const MACHINE_DEPENDENT: &[&str] = &["MediaBrowser:default"]; + +/// (scenario, findings, of them unnamed controls) per not-yet-fixed scenario, as measured in the +/// shared docking layout (Effect Controls with one Gaussian Blur on the layer, every other panel +/// as it opens). A ratchet: a number may only go down, never up; when a pass over a panel lowers +/// it, lower its entry in the same change, and remove the entry (and add the panel to `FIXED`) +/// when it reaches zero. A scenario that is not listed is held to zero. +const BASELINE: &[(&str, usize, usize)] = &[ + ("EffectControls:default", 28, 14), + ("Composition:default", 4, 2), + ("Layer:default", 2, 1), + ("Timeline:default", 89, 42), + ("Audio:default", 4, 2), + ("Properties:default", 31, 15), + ("Character:default", 46, 22), + ("Paragraph:default", 26, 13), + ("Align:default", 12, 0), + ("Tracker:default", 11, 3), + ("Wiggler:default", 4, 2), + ("Smoother:default", 2, 1), + ("MotionSketch:default", 8, 4), + ("Paint:default", 38, 18), + ("Brushes:default", 34, 6), + ("Flowchart:default", 5, 1), + ("History:default", 10, 5), + ("MaskInterpolation:default", 16, 8), + ("ScriptConsole:default", 5, 3), + ("EssentialGraphics:default", 1, 1), + ("LumetriScopes:default", 2, 1), + ("MediaBrowser:default", 20, 10), + ("Metadata:default", 2, 2), + ("ContentAwareFill:default", 2, 1), +]; + +/// How many controls the fixed scenarios must at least show, so that "zero findings" is never +/// the result of looking at nothing. Any scenario not listed needs 2. +const MIN_CONTROLS: &[(&str, usize)] = &[ + ("EffectsPresets:default", 10), + ("EffectsPresets:open", 10), + ("EffectsPresets:search", 5), + ("EffectsPresets:menu", 8), + ("Settings:general", 12), + ("Settings:appearance", 15), + ("Settings:labels", 30), + ("RenderQueue:queued", 8), + ("RenderQueue:expanded", 12), + ("RenderQueue:menu", 20), + ("RenderQueue:templates:renderSettings", 8), + ("RenderQueue:templates:outputModule", 8), +]; + +/// The roles that are controls: anything with one of these (or with a click or focus action) is +/// held to the rules below. +const CONTROL_ROLES: &[Role] = &[ + Role::Button, + Role::CheckBox, + Role::ColorWell, + Role::ComboBox, + Role::Link, + Role::ListBoxOption, + Role::ListItem, + Role::MenuItem, + Role::MenuItemCheckBox, + Role::MenuItemRadio, + Role::MultilineTextInput, + Role::RadioButton, + Role::SearchInput, + Role::Slider, + Role::SpinButton, + Role::Switch, + Role::Tab, + Role::TextInput, + Role::TreeItem, +]; + +#[derive(Debug, Clone)] +struct Finding { + scenario: String, + problem: &'static str, + role: Role, + name: String, + rect: [i32; 4], +} + +impl std::fmt::Display for Finding { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "[{}] {}: {:?} {:?} at {:?}", self.scenario, self.problem, self.role, self.name, self.rect) + } +} + +fn app_session() -> Session { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Red", "color": "#c04040"})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Blue", "color": "#4040c0"})).unwrap(); + s.execute("effect.apply", json!({"effect": "Gaussian Blur"})).ok(); + s +} + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(app_session())); + // The default workspace gives Preview a short frame at the top of the right column; its + // controls below that frame stay in the tree and would be counted against the panel under it. + // The per-panel pass opens Preview again for its own scenario. + h.state_mut().close_panel(PanelKind::Preview); + h.run_steps(4); + h +} + +fn invoke(h: &mut Harness<'_, EffectcraftApp>, id: &str, params: serde_json::Value) { + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, id, params).unwrap(); + h.run_steps(4); +} + +fn panel_rect(h: &Harness<'_, EffectcraftApp>, p: PanelKind) -> Option { + let e = h.state().auto.find(&format!("panel.{}", p.id()))?; + Some(Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))) +} + +fn dialog_rect(h: &Harness<'_, EffectcraftApp>, title: &str) -> Option { + h.ctx.memory(|m| m.area_rect(egui::Id::unique(("modal", title)))) +} + +/// The ids of every node now, so a later audit can look at what appeared since (a popup). +fn node_ids(h: &Harness<'_, EffectcraftApp>) -> HashSet { + h.root().children_recursive().map(|n| format!("{:?}", n.accesskit_node().id())).collect() +} + +/// Walk the tree and check every control whose centre is inside `scope` (and, when `since` is +/// given, that was not in the tree before). +fn audit(h: &Harness<'_, EffectcraftApp>, scenario: &str, scope: Rect, since: Option<&HashSet>) -> (Vec, usize) { + let mut out = Vec::new(); + let mut controls = 0; + for n in h.root().children_recursive() { + let a = n.accesskit_node(); + if a.is_hidden() { + continue; + } + let Some(b) = a.bounding_box() else { + // No bounds: assistive technology cannot place it. Only a control is a finding (and + // it is one wherever it is: there is no position to scope it by). + if CONTROL_ROLES.contains(&a.role()) { + out.push(Finding { scenario: scenario.to_string(), problem: "no bounds", role: a.role(), name: a.label().unwrap_or_default(), rect: [0; 4] }); + } + continue; + }; + if !scope.contains(pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)) { + continue; + } + if since.is_some_and(|s| s.contains(&format!("{:?}", a.id()))) { + continue; + } + let role = a.role(); + let actionable = a.data().supports_action(Action::Click) || a.data().supports_action(Action::Focus); + let control = CONTROL_ROLES.contains(&role); + if !(control || (actionable && matches!(role, Role::Unknown | Role::GenericContainer))) { + continue; + } + let name = a.label().unwrap_or_default(); + controls += 1; + let finding = |problem: &'static str| Finding { + scenario: scenario.to_string(), + problem, + role, + name: name.clone(), + rect: [b.x0 as i32, b.y0 as i32, b.x1 as i32, b.y1 as i32], + }; + if !control && matches!(role, Role::Unknown | Role::GenericContainer) { + // Something you can click or focus, that no role describes. + out.push(finding("clickable or focusable without a control role")); + } + if name.trim().is_empty() { + out.push(finding("no name")); + } + let state_missing = match role { + Role::CheckBox | Role::RadioButton | Role::Switch | Role::MenuItemCheckBox | Role::MenuItemRadio => { + a.toggled().is_none() || a.toggled() == Some(Toggled::Mixed) + } + Role::Slider | Role::SpinButton => a.numeric_value().is_none(), + Role::ComboBox => a.value().is_none_or(|v| v.trim().is_empty()), + Role::TreeItem | Role::ListItem | Role::ListBoxOption | Role::Tab => a.is_selected().is_none(), + _ => false, + }; + if state_missing { + out.push(finding("no state or value")); + } + } + (out, controls) +} + +fn count_unnamed(f: &[Finding]) -> usize { + f.iter().filter(|f| f.problem == "no name").count() +} + +struct Report { + findings: Vec, + /// (scenario, findings, of them unnamed, controls looked at) + rows: Vec<(String, usize, usize, usize)>, +} + +impl Report { + fn add(&mut self, scenario: &str, (f, controls): (Vec, usize)) { + self.rows.push((scenario.to_string(), f.len(), count_unnamed(&f), controls)); + self.findings.extend(f); + } +} + +fn group(scenario: &str) -> &str { + scenario.split(':').next().unwrap_or(scenario) +} + +fn collect() -> Report { + let mut r = Report { findings: vec![], rows: vec![] }; + + // ---- Effects & Presets: closed, everything open, searching, the contents menu. + let mut h = harness(); + h.state_mut().show_panel(PanelKind::EffectsPresets); + h.run_steps(4); + let rect = panel_rect(&h, PanelKind::EffectsPresets).expect("Effects & Presets is drawn"); + r.add("EffectsPresets:default", audit(&h, "EffectsPresets:default", rect, None)); + { + let s = h.state_mut(); + s.session.prefs.toggle_effect_favorite("ec.blur.gaussian"); + s.ui.effects_open.insert("favorites".into()); + s.ui.effects_open.insert("presets".into()); + s.ui.effects_open.insert("presets/Text".into()); + s.ui.effects_open.insert("Blur & Sharpen".into()); + } + h.run_steps(4); + r.add("EffectsPresets:open", audit(&h, "EffectsPresets:open", rect, None)); + h.state_mut().ui.effects_search = "blur".into(); + h.run_steps(4); + r.add("EffectsPresets:search", audit(&h, "EffectsPresets:search", rect, None)); + let before = node_ids(&h); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("fx-contents-menu").with("open"), true)); + h.run_steps(4); + let screen = Rect::from_min_size(pos2(0.0, 0.0), egui::vec2(1600.0, 1000.0)); + r.add("EffectsPresets:menu", audit(&h, "EffectsPresets:menu", screen, Some(&before))); + + // ---- Settings: every page. + let mut h = harness(); + let before = node_ids(&h); + for page in effectcraft_engine::prefs::pages() { + invoke(&mut h, "app.settings", json!({"page": page.id})); + let name = format!("Settings:{}", page.id); + let rect = dialog_rect(&h, "Settings").expect("the Settings dialog is open"); + r.add(&name, audit(&h, &name, rect, Some(&before))); + } + + // ---- Render Queue: empty, queued and expanded, a menu open, the Templates dialogs. + let mut h = harness(); + h.state_mut().show_panel(PanelKind::RenderQueue); + h.run_steps(4); + let rect = panel_rect(&h, PanelKind::RenderQueue).expect("the Render Queue is drawn"); + r.add("RenderQueue:empty", audit(&h, "RenderQueue:empty", rect, None)); + let comp = h.state().session.active_comp_id().expect("a comp").0; + let mut items = vec![]; + for _ in 0..2 { + let out = h.state_mut().session.execute("renderQueue.add", json!({"comp": comp})).unwrap(); + items.push(out["item"].as_u64().or_else(|| out["items"][0].as_u64()).unwrap_or(0)); + } + h.run_steps(4); + r.add("RenderQueue:queued", audit(&h, "RenderQueue:queued", rect, None)); + let first = h.state().session.project.render_queue.first().map(|i| i.id).expect("a queue item"); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("rq-ui"), (vec![first], Some(first)))); + h.run_steps(4); + r.add("RenderQueue:expanded", audit(&h, "RenderQueue:expanded", rect, None)); + let before = node_ids(&h); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique(("rq-dd", first, 0)).with("open"), true)); + h.run_steps(4); + r.add("RenderQueue:menu", audit(&h, "RenderQueue:menu", screen, Some(&before))); + h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique(("rq-dd", first, 0)).with("open"), false)); + let before = node_ids(&h); + for (kind, title) in [("renderSettings", "Render Settings Templates"), ("outputModule", "Output Module Templates")] { + invoke(&mut h, "app.templates", json!({"kind": kind})); + let name = format!("RenderQueue:templates:{kind}"); + let rect = dialog_rect(&h, title).expect("the Templates dialog is open"); + r.add(&name, audit(&h, &name, rect, Some(&before))); + } + let _ = items; + + // ---- Everything else, informational: each panel on its own. + let mut h = harness(); + for p in PanelKind::ALL { + if matches!(p, PanelKind::EffectsPresets | PanelKind::RenderQueue) { + continue; + } + h.state_mut().show_panel(p); + h.run_steps(3); + let name = format!("{}:default", p.id()); + let Some(rect) = panel_rect(&h, p) else { + // A panel with a baseline entry must be looked at, or its allowance would pass unchecked. + assert!(!BASELINE.iter().any(|(k, ..)| *k == name), "{name} has a BASELINE entry but was not drawn"); + r.rows.push((format!("{name} (not drawn)"), 0, 0, 0)); + continue; + }; + r.add(&name, audit(&h, &name, rect, None)); + if p == PanelKind::Preview { + // Closed again so it is not counted against the panels after it (see `harness`). + h.state_mut().close_panel(PanelKind::Preview); + h.run_steps(3); + } + } + r +} + +#[test] +fn fixed_panels_have_no_unnamed_or_roleless_controls_and_the_others_do_not_get_worse() { + let report = collect(); + println!("\nAccessibility audit: findings per scenario (unnamed in brackets)"); + let mut by_group: Vec<(String, usize, usize, usize)> = vec![]; + for (name, n, unnamed, controls) in &report.rows { + println!(" {name:<44} {n:>4} findings ({unnamed} unnamed) of {controls} controls"); + let g = group(name).to_string(); + match by_group.iter_mut().find(|(k, ..)| *k == g) { + Some(e) => { + e.1 += n; + e.2 += unnamed; + e.3 += controls; + } + None => by_group.push((g, *n, *unnamed, *controls)), + } + } + println!("\nBy panel (findings, unnamed controls, controls looked at):"); + for (g, n, unnamed, controls) in &by_group { + let state = if FIXED.contains(&g.as_str()) { "fixed: must be 0" } else { "not yet audited" }; + println!(" {g:<28} {n:>4} findings, {unnamed:>3} unnamed, {controls:>4} controls {state}"); + } + let total_unnamed: usize = by_group.iter().map(|(_, _, u, _)| u).sum(); + println!(" all panels: {total_unnamed} unnamed controls\n"); + + let mut failures = vec![]; + for f in &report.findings { + if FIXED.contains(&group(&f.scenario)) { + failures.push(f.to_string()); + } + } + assert!(failures.is_empty(), "{} accessibility findings in the fixed panels:\n{}", failures.len(), failures.join("\n")); + + // A clean panel must not be clean because nothing was looked at. + for (name, _, _, controls) in &report.rows { + if FIXED.contains(&group(name)) { + let min = MIN_CONTROLS.iter().find(|(k, _)| k == name).map_or(2, |(_, v)| *v); + assert!(*controls >= min, "{name}: only {controls} controls found in the tree (expected {min}); the scenario is not showing the panel"); + } + } + + // The ratchet: a count above its entry is a regression, a count below it must be written back + // (else the old, higher allowance would let a later regression through). + let mut off = vec![]; + for (name, n, unnamed, _) in &report.rows { + if FIXED.contains(&group(name)) { + continue; + } + let (allowed, allowed_unnamed) = BASELINE.iter().find(|(k, ..)| k == name).map_or((0, 0), |(_, v, u)| (*v, *u)); + if *n > allowed || *unnamed > allowed_unnamed { + off.push(format!("{name}: {n} findings ({unnamed} unnamed), the baseline allows {allowed} ({allowed_unnamed}); a panel must not get worse")); + } else if (*n < allowed || *unnamed < allowed_unnamed) && !MACHINE_DEPENDENT.contains(&name.as_str()) { + off.push(format!("{name}: improved to {n} findings ({unnamed} unnamed); lower its BASELINE entry to ({n}, {unnamed})")); + } + } + assert!(off.is_empty(), "{} scenarios do not match BASELINE:\n{}", off.len(), off.join("\n")); +} diff --git a/crates/ui-egui/tests/ui_contrast.rs b/crates/ui-egui/tests/ui_contrast.rs new file mode 100644 index 000000000..34352b5e5 --- /dev/null +++ b/crates/ui-egui/tests/ui_contrast.rs @@ -0,0 +1,92 @@ +//! Colour contrast of the theme tokens (docs/gaps.md gap 25), separate from the AccessKit audit: +//! the WCAG 2 contrast ratio of the text, accent and focus colours against the backgrounds they +//! are drawn on, for every theme. Text needs 4.5:1, the focus ring and control borders 3:1. +//! +//! Pairs that fall short today are listed in [`KNOWN_BELOW`] with the ratio measured when this was +//! written. The list is a ratchet: a pair must not get worse, and a pair that reaches its target +//! must leave the list. This checks the tokens, not what a panel really draws with them (text on +//! a label colour, an image, a disabled control) and not colour-blindness. + +use effectcraft_ui_egui::theme::{ThemeKind, Tokens}; +use egui::Color32; + +/// WCAG 2 relative luminance of an sRGB colour. +fn luminance(c: Color32) -> f64 { + let lin = |v: u8| { + let v = f64::from(v) / 255.0; + if v <= 0.039_28 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b()) +} + +/// WCAG 2 contrast ratio of two opaque colours. +fn ratio(a: Color32, b: Color32) -> f64 { + let (la, lb) = (luminance(a), luminance(b)); + (la.max(lb) + 0.05) / (la.min(lb) + 0.05) +} + +/// (name, foreground, background, minimum ratio) +fn pairs(t: &Tokens) -> Vec<(&'static str, Color32, Color32, f64)> { + vec![ + ("text on panel", t.text, t.panel_bg, 4.5), + ("text on field", t.text, t.field_bg, 4.5), + ("text on selected row", t.text, t.row_selected, 4.5), + ("text on row", t.text, t.row, 4.5), + ("dim text on panel", t.text_dim, t.panel_bg, 4.5), + ("faint text on panel", t.text_faint, t.panel_bg, 4.5), + ("active tab text on panel", t.tab_text_active, t.panel_bg, 4.5), + ("tab text on header", t.tab_text, t.header_bg, 4.5), + ("hot text on panel", t.hot_text, t.panel_bg, 4.5), + ("white on accent (primary button)", Color32::WHITE, t.accent, 4.5), + ("danger text on panel", t.danger, t.panel_bg, 4.5), + ("warning text on panel", t.warning, t.panel_bg, 4.5), + ("accent (focus ring) on panel", t.accent, t.panel_bg, 3.0), + ("focus colour on panel", t.focus, t.panel_bg, 3.0), + ("field border on panel", t.field_border, t.panel_bg, 3.0), + ("icon on panel", t.icon, t.panel_bg, 3.0), + ] +} + +/// (theme, pair, ratio measured when written). Ratchet: never below the number; remove the entry +/// when the pair reaches its target. +const KNOWN_BELOW: &[(&str, &str, f64)] = &[ + ("dark", "white on accent (primary button)", 3.46), + ("dark", "danger text on panel", 3.90), + ("dark", "field border on panel", 1.38), + ("darker", "white on accent (primary button)", 3.46), + ("darker", "danger text on panel", 4.37), + ("darker", "field border on panel", 1.55), + ("light", "faint text on panel", 4.46), + ("light", "white on accent (primary button)", 3.46), + ("light", "danger text on panel", 3.23), + ("light", "warning text on panel", 1.85), + ("light", "accent (focus ring) on panel", 2.78), + ("light", "focus colour on panel", 2.78), + ("light", "field border on panel", 1.86), +]; + +#[test] +fn print_and_hold_the_theme_contrast() { + let mut failures = vec![]; + for kind in [ThemeKind::Dark, ThemeKind::Darker, ThemeKind::Light] { + let t = Tokens::for_kind(kind); + for (name, fg, bg, min) in pairs(&t) { + let r = ratio(fg, bg); + let known = KNOWN_BELOW.iter().find(|(k, n, _)| *k == kind.name() && *n == name); + println!("{:<7} {name:<36} {r:>5.2}:1 (needs {min})", kind.name()); + match known { + Some((_, _, floor)) => { + if r + 0.005 < *floor { + failures.push(format!("{} / {name}: {r:.2}:1 is worse than the known {floor:.2}:1", kind.name())); + } + if r >= min { + failures.push(format!("{} / {name}: {r:.2}:1 now reaches {min}:1; remove it from KNOWN_BELOW", kind.name())); + } + } + None if r < min => failures.push(format!("{} / {name}: {r:.2}:1 is below {min}:1", kind.name())), + None => {} + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} diff --git a/crates/ui-egui/tests/ui_effects_presets_keyboard.rs b/crates/ui-egui/tests/ui_effects_presets_keyboard.rs new file mode 100644 index 000000000..bf354ac00 --- /dev/null +++ b/crates/ui-egui/tests/ui_effects_presets_keyboard.rs @@ -0,0 +1,279 @@ +//! Effects & Presets keyboard operation and accessible names (docs/gaps.md gap 25): the list is one +//! Tab stop; the arrow keys, Home / End, Right / Left, Enter and Space work it; the focus follows +//! the cursor; rows are tree items with a name, a selected state, a depth and (folders) an open +//! state; the favourite star is a named button. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui::accesskit::Role; +use egui::{Event, Key, Modifiers, Pos2, pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Red", "color": "#c04040"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::EffectsPresets); + h.run_steps(4); + h +} + +fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); + pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = center(h, id); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); +} + +fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) { + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); + h.step(); + } + h.run_steps(3); +} + +/// The role and name of the node that has the keyboard focus. +fn focused(h: &Harness<'_, EffectcraftApp>) -> Option<(Role, String)> { + h.root().children_recursive().find(|n| n.is_focused()).map(|n| (n.accesskit_node().role(), n.accesskit_node().label().unwrap_or_default())) +} + +fn selected_rows(h: &Harness<'_, EffectcraftApp>) -> Vec { + let panel = h.state().auto.find("panel.EffectsPresets").expect("the panel is drawn").rect; + let panel = egui::Rect::from_min_size(pos2(panel[0], panel[1]), egui::vec2(panel[2], panel[3])); + h.root() + .children_recursive() + .filter(|n| n.accesskit_node().role() == Role::TreeItem && n.accesskit_node().is_selected() == Some(true)) + .filter(|n| n.accesskit_node().bounding_box().is_some_and(|b| panel.contains(pos2(b.x0 as f32 + 1.0, b.y0 as f32 + 1.0)))) + .map(|n| n.accesskit_node().label().unwrap_or_default()) + .collect() +} + +/// Put the keyboard on the first row the way Tab does: from the search field, the menu button, +/// then the list. +fn tab_into_list(h: &mut Harness<'_, EffectcraftApp>) { + h.get_by_label("Search effects and presets").focus(); + h.run_steps(3); + key(h, Key::Tab, Modifiers::NONE); + assert_eq!(focused(h).map(|f| f.1), Some("Effects & Presets options".to_string()), "Tab leaves the search field for the options button"); + key(h, Key::Tab, Modifiers::NONE); +} + +#[test] +fn tab_reaches_the_list_once_and_leaves_it() { + let mut h = harness(); + tab_into_list(&mut h); + let (role, name) = focused(&h).expect("a row has the focus"); + assert_eq!(role, Role::TreeItem); + assert!(!name.is_empty()); + // The first row is a folder: no star. The next Tab leaves the list instead of walking the + // hundreds of effects. + key(&mut h, Key::Tab, Modifiers::NONE); + let after = focused(&h); + assert!(after.as_ref().is_none_or(|(r, _)| *r != Role::TreeItem), "the second Tab is past the list: {after:?}"); +} + +#[test] +fn arrows_home_end_move_the_cursor_and_the_focus() { + let mut h = harness(); + tab_into_list(&mut h); + let first = focused(&h).unwrap().1; + assert_eq!(selected_rows(&h), Vec::::new(), "Tab alone selects nothing"); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + let second = focused(&h).unwrap().1; + assert_ne!(first, second, "Down Arrow moves to the next row"); + assert_eq!(selected_rows(&h), vec![second.clone()], "the row the cursor is on is selected"); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + assert_eq!(focused(&h).unwrap().1, first); + key(&mut h, Key::End, Modifiers::NONE); + let last = focused(&h).unwrap().1; + assert_ne!(last, first, "End goes to the last row"); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(focused(&h).unwrap().1, last, "Down Arrow stays on the last row"); + key(&mut h, Key::Home, Modifiers::NONE); + assert_eq!(focused(&h).unwrap().1, first, "Home goes to the first row"); + key(&mut h, Key::PageDown, Modifiers::NONE); + assert_ne!(focused(&h).unwrap().1, first, "Page Down moves a page"); + // The cursor row is scrolled into view: its node is on screen. + let name = focused(&h).unwrap().1; + let r = h.get_by_label(&name).rect(); + assert!(r.min.y >= 0.0 && r.max.y <= 1000.0, "the focused row is on screen: {r:?}"); +} + +#[test] +fn right_and_left_open_and_close_folders_and_step_through_the_tree() { + let mut h = harness(); + h.state_mut().ui.effects_search.clear(); + tab_into_list(&mut h); + // Walk to a category folder (not the presets folder). + let mut guard = 0; + loop { + let n = focused(&h).unwrap().1; + if n == "Blur & Sharpen" { + break; + } + key(&mut h, Key::ArrowDown, Modifiers::NONE); + guard += 1; + assert!(guard < 40, "no Blur & Sharpen folder reached"); + } + let folder = h.get_by_label("Blur & Sharpen"); + assert_eq!(folder.accesskit_node().data().is_expanded(), Some(false)); + assert_eq!(folder.accesskit_node().data().level(), Some(1)); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert!(h.state().ui.effects_open.contains("Blur & Sharpen"), "Right Arrow opens the folder"); + assert_eq!(h.get_by_label("Blur & Sharpen").accesskit_node().data().is_expanded(), Some(true)); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + let child = focused(&h).unwrap(); + assert_eq!(child.0, Role::TreeItem); + assert_ne!(child.1, "Blur & Sharpen", "Right Arrow on an open folder steps into it"); + assert_eq!(h.get_by_label(&child.1).accesskit_node().data().level(), Some(2)); + key(&mut h, Key::ArrowLeft, Modifiers::NONE); + assert_eq!(focused(&h).unwrap().1, "Blur & Sharpen", "Left Arrow on an effect steps out to its folder"); + key(&mut h, Key::ArrowLeft, Modifiers::NONE); + assert!(!h.state().ui.effects_open.contains("Blur & Sharpen"), "Left Arrow closes the folder"); + key(&mut h, Key::Enter, Modifiers::NONE); + assert!(h.state().ui.effects_open.contains("Blur & Sharpen"), "Enter opens a closed folder"); + key(&mut h, Key::Space, Modifiers::NONE); + assert!(!h.state().ui.effects_open.contains("Blur & Sharpen"), "Space closes it again"); +} + +#[test] +fn enter_and_space_apply_an_effect_to_the_selected_layer() { + for apply_key in [Key::Enter, Key::Space] { + let mut h = harness(); + tab_into_list(&mut h); + h.state_mut().ui.effects_open.insert("Blur & Sharpen".into()); + h.run_steps(3); + let mut guard = 0; + while focused(&h).map(|f| f.1) != Some("Blur & Sharpen".to_string()) { + key(&mut h, Key::ArrowDown, Modifiers::NONE); + guard += 1; + assert!(guard < 40); + } + key(&mut h, Key::ArrowDown, Modifiers::NONE); + let effect = focused(&h).unwrap().1; + assert!(h.state().session.state.last_effect.is_none()); + key(&mut h, apply_key, Modifiers::NONE); + let applied = h.state().session.state.last_effect.clone(); + assert!(applied.is_some(), "{apply_key:?} on `{effect}` applies it to the selected layer"); + let pb = &h.state().playback; + assert!(!pb.playing && !pb.caching && pb.plan.is_none(), "{apply_key:?} on a focused row is the row's, it does not start a preview"); + } +} + +#[test] +fn the_star_is_a_named_button_on_the_cursor_row_and_enter_toggles_it() { + let mut h = harness(); + tab_into_list(&mut h); + h.state_mut().ui.effects_open.insert("Blur & Sharpen".into()); + h.run_steps(3); + let mut guard = 0; + while focused(&h).map(|f| f.1) != Some("Blur & Sharpen".to_string()) { + key(&mut h, Key::ArrowDown, Modifiers::NONE); + guard += 1; + assert!(guard < 40, "no Blur & Sharpen folder reached"); + } + key(&mut h, Key::ArrowDown, Modifiers::NONE); + let effect = focused(&h).unwrap().1; + key(&mut h, Key::Tab, Modifiers::NONE); + let (role, name) = focused(&h).expect("the star has the focus"); + assert_eq!(role, Role::Button); + assert_eq!(name, format!("Add to favorites: {effect}")); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(h.state().session.prefs.effects_favorites.len(), 1, "Enter on the star adds the favourite"); + let (_, name) = focused(&h).expect("the star keeps the focus"); + assert_eq!(name, format!("Remove from favorites: {effect}")); +} + +#[test] +fn the_search_field_keeps_its_arrows_and_the_layer_does_not_nudge() { + let mut h = harness(); + h.get_by_label("Search effects and presets").focus(); + h.run_steps(3); + h.get_by_label("Search effects and presets").type_text("blur"); + h.run_steps(3); + assert_eq!(h.state().ui.effects_search, "blur"); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert!(selected_rows(&h).is_empty(), "arrows in the search field belong to the field"); + // A click on a row, then the arrows: the list moves, the selected layer stays where it is. + let layer_before = layer_position(&h); + h.state_mut().ui.effects_search.clear(); + h.run_steps(3); + click(&mut h, "effects.category.presets"); + assert_eq!(h.state().ui.focused, PanelKind::EffectsPresets); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(selected_rows(&h).len(), 1, "the arrows work after a click, without keyboard focus on a row"); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert_eq!(layer_position(&h), layer_before, "the arrow keys did not nudge the selected layer"); +} + +fn layer_position(h: &Harness<'_, EffectcraftApp>) -> String { + let s = &h.state().session; + let comp = s.active_comp().expect("a comp"); + let layer = comp.layers.first().expect("a layer"); + format!("{:?}", layer.transform()) +} + +#[test] +fn rows_and_controls_have_names_roles_and_states() { + let mut h = harness(); + h.state_mut().ui.effects_open.insert("Blur & Sharpen".into()); + h.state_mut().session.prefs.toggle_effect_favorite("ec.blur.gaussian"); + h.run_steps(4); + assert_eq!(h.get_by_label("Search effects and presets").accesskit_node().role(), Role::TextInput); + let menu = h.get_by_label("Effects & Presets options"); + assert_eq!(menu.accesskit_node().role(), Role::Button); + assert_eq!(menu.accesskit_node().data().is_expanded(), Some(false), "the options button says its menu is closed"); + let folder = h.get_by_label("Blur & Sharpen"); + assert_eq!(folder.accesskit_node().role(), Role::TreeItem); + assert_eq!(folder.accesskit_node().data().is_expanded(), Some(true)); + assert_eq!(folder.accesskit_node().is_selected(), Some(false)); + let favorites = h.get_by_label("Favorites"); + assert_eq!(favorites.accesskit_node().data().is_expanded(), Some(false)); + // Every effect row in an open category is a leaf at depth 2. + let leaves: Vec<_> = + h.root().children_recursive().filter(|n| n.accesskit_node().role() == Role::TreeItem && n.accesskit_node().data().level() == Some(2)).collect(); + assert!(!leaves.is_empty()); + assert!(leaves.iter().all(|n| n.accesskit_node().data().is_expanded().is_none()), "effects are leaves: they have no open state"); + let gaussian_rows = h + .root() + .children_recursive() + .filter(|n| n.accesskit_node().role() == Role::TreeItem && n.accesskit_node().label().as_deref() == Some("Gaussian Blur")) + .count(); + assert!(gaussian_rows <= 1, "a favourite hidden in a closed Favorites folder draws no rows"); + assert!(h.query_all_by_label_contains("Add to favorites: ").count() > 1, "the effects' stars are named"); +} + +/// A pick from the options menu with the mouse leaves no widget with the keyboard focus, and Enter +/// after a click in the panel is the panel's: it opens or applies the row and does not start the +/// layer rename the Timeline binds to Enter. +#[test] +fn mouse_picks_leave_no_focus_and_enter_after_a_click_is_the_panels() { + let mut h = harness(); + click(&mut h, "effects.menu"); + assert!(h.ctx.memory(|m| m.focused()).is_none(), "opening the menu with the mouse takes no keyboard focus"); + h.get_by_label_contains("Alphabetical").click(); + h.run_steps(4); + assert!(h.ctx.memory(|m| m.focused()).is_none(), "choosing an entry with the mouse leaves no keyboard focus"); + assert!(h.state().ui.effects_view.alphabetical); + h.state_mut().ui.effects_view.alphabetical = false; + h.run_steps(3); + click(&mut h, "effects.category.presets"); + let open = h.state().ui.effects_open.contains("presets"); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_ne!(h.state().ui.effects_open.contains("presets"), open, "Enter toggles the clicked folder"); + assert!(h.state().auto.query("timeline.layer.").iter().all(|e| !e.id.ends_with(".nameEdit")), "no layer rename started"); +} diff --git a/crates/ui-egui/tests/ui_render_queue_keyboard.rs b/crates/ui-egui/tests/ui_render_queue_keyboard.rs new file mode 100644 index 000000000..3f954b7b6 --- /dev/null +++ b/crates/ui-egui/tests/ui_render_queue_keyboard.rs @@ -0,0 +1,226 @@ +//! Render Queue keyboard operation and accessible names (docs/gaps.md gap 25): queue items are +//! tree items (named by number, comp and status; selected; open or closed); the arrow keys, Home / +//! End, Right / Left, Enter and Delete work them; the buttons, the check boxes, the twirls and the +//! drop-downs have names and states; a drop-down opens from the keyboard and closes with the focus +//! back on it; the Templates dialog is named. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui::accesskit::Role; +use egui::{Event, Key, Modifiers, Pos2, pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + for name in ["Alpha", "Beta", "Gamma"] { + let comp = s.execute("comp.new", json!({"name": name, "width": 320, "height": 180, "duration": 4})).unwrap()["comp"].as_u64().unwrap(); + s.execute("renderQueue.add", json!({"comp": comp})).unwrap(); + } + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::RenderQueue); + h.run_steps(4); + h +} + +fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) { + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); + h.step(); + } + h.run_steps(3); +} + +fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); + pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = center(h, id); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); +} + +fn focused(h: &Harness<'_, EffectcraftApp>) -> Option<(Role, String)> { + h.root().children_recursive().find(|n| n.is_focused()).map(|n| (n.accesskit_node().role(), n.accesskit_node().label().unwrap_or_default())) +} + +fn queue_len(h: &Harness<'_, EffectcraftApp>) -> usize { + h.state().session.project.render_queue.len() +} + +fn row_name(h: &Harness<'_, EffectcraftApp>, n: usize) -> String { + h.query_all_by_role(Role::TreeItem).map(|r| r.accesskit_node().label().unwrap_or_default()).find(|l| l.starts_with(&format!("{n}. "))).unwrap_or_default() +} + +#[test] +fn rows_are_named_tree_items_with_selected_and_open_state() { + let mut h = harness(); + let first = row_name(&h, 1); + assert!(first.starts_with("1. Alpha, "), "number, comp and status: {first}"); + let node = h.get_by_role_and_label(Role::TreeItem, &first); + assert_eq!(node.accesskit_node().is_selected(), Some(false)); + assert_eq!(node.accesskit_node().data().is_expanded(), Some(false)); + assert_eq!(node.accesskit_node().data().level(), Some(1)); + click(&mut h, "renderQueue.item.1.twirl"); + let node = h.get_by_role_and_label(Role::TreeItem, &first); + assert_eq!(node.accesskit_node().data().is_expanded(), Some(true), "the open item says so"); + let twirl = h.get_by_label("Hide details of Alpha"); + assert_eq!(twirl.accesskit_node().role(), Role::Button); + assert_eq!(twirl.accesskit_node().data().is_expanded(), Some(true)); + // The settings drop-downs of the open item: what they set and what they are set to. + let settings = h.get_by_role_and_label(Role::ComboBox, "Render Settings"); + assert!(settings.accesskit_node().value().is_some_and(|v| !v.is_empty())); + assert!(h.query_by_role_and_label(Role::ComboBox, "Output Module").is_some()); + assert!(h.query_by_role_and_label(Role::ComboBox, "Log").is_some()); + assert!(h.query_by_role_and_label(Role::ComboBox, "Output To").is_some()); + assert!(h.query_by_role_and_label(Role::Button, "Render").is_some()); + let notify = h.get_by_role_and_label(Role::CheckBox, "Notify"); + assert!(notify.accesskit_node().toggled().is_some()); + let render = h.get_by_role_and_label(Role::CheckBox, "Render: Alpha"); + assert_eq!(render.accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); + assert_eq!(h.get_by_role(Role::ProgressIndicator).accesskit_node().data().numeric_value(), Some(0.0)); +} + +#[test] +fn arrows_home_end_right_left_enter_and_delete_work_the_rows() { + let mut h = harness(); + h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).focus(); + h.run_steps(3); + assert_eq!(focused(&h).map(|f| f.1), Some(row_name(&h, 1))); + let selected = |h: &Harness<'_, EffectcraftApp>| { + h.query_all_by_role(Role::TreeItem) + .filter(|n| n.accesskit_node().is_selected() == Some(true)) + .map(|n| n.accesskit_node().label().unwrap_or_default()) + .filter(|l| l.starts_with(|c: char| c.is_ascii_digit())) + .collect::>() + }; + assert_eq!(selected(&h), vec![row_name(&h, 1)], "a focused row is selected"); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(row_name(&h, 2)), "Down Arrow moves to the next item and the focus goes with it"); + assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 2)).accesskit_node().is_selected(), Some(true)); + key(&mut h, Key::End, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(row_name(&h, 3))); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(row_name(&h, 3)), "Down Arrow stays on the last item"); + key(&mut h, Key::Home, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(row_name(&h, 1))); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).accesskit_node().data().is_expanded(), Some(true), "Right Arrow opens the item"); + key(&mut h, Key::ArrowLeft, Modifiers::NONE); + assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).accesskit_node().data().is_expanded(), Some(false), "Left Arrow closes it"); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).accesskit_node().data().is_expanded(), Some(true), "Enter toggles it"); + key(&mut h, Key::Space, Modifiers::NONE); + assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).accesskit_node().data().is_expanded(), Some(false), "Space toggles it back"); + assert!(!h.state().playback.playing && !h.state().playback.caching && h.state().playback.plan.is_none(), "Space on a row does not start a preview"); + // Delete removes the selected item, as it does with the mouse's selection. + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(queue_len(&h), 3); + key(&mut h, Key::Delete, Modifiers::NONE); + assert_eq!(queue_len(&h), 2, "Delete removes the selected item"); +} + +#[test] +fn tab_reaches_every_control_of_a_row() { + let mut h = harness(); + h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).focus(); + h.run_steps(3); + let mut seen = vec![focused(&h).unwrap()]; + for _ in 0..40 { + key(&mut h, Key::Tab, Modifiers::NONE); + match focused(&h) { + Some(f) if !seen.contains(&f) => seen.push(f), + _ => {} + } + } + let names: Vec<&str> = seen.iter().map(|s| s.1.as_str()).collect(); + for want in ["Show details of Alpha", "Render: Alpha", "Show details of Beta", "Render: Beta"] { + assert!(names.contains(&want), "Tab reaches `{want}`; reached {names:?}"); + } +} + +#[test] +fn a_drop_down_opens_from_the_keyboard_and_gives_the_focus_back() { + let mut h = harness(); + click(&mut h, "renderQueue.item.1.twirl"); + h.get_by_role_and_label(Role::ComboBox, "Render Settings").focus(); + h.run_steps(3); + key(&mut h, Key::Enter, Modifiers::NONE); + let (role, first_entry) = focused(&h).expect("the first entry has the focus"); + assert_ne!(role, Role::ComboBox, "the keyboard is in the menu: {first_entry}"); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + let second = focused(&h).expect("Down Arrow moves to the next entry").1; + assert_ne!(second, first_entry); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(first_entry), "Up Arrow moves back"); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.0), Some(Role::ComboBox), "choosing an entry gives the focus back to the drop-down"); +} + +#[test] +fn the_check_box_and_the_buttons_work_from_the_keyboard() { + let mut h = harness(); + let before = h.state().session.project.render_queue.first().map(|i| i.render); + h.get_by_role_and_label(Role::CheckBox, "Render: Alpha").focus(); + h.run_steps(3); + key(&mut h, Key::Space, Modifiers::NONE); + assert_ne!(h.state().session.project.render_queue.first().map(|i| i.render), before, "Space toggles the Render check box"); + let notify = h.state().session.project.render_prefs.notify; + h.get_by_role_and_label(Role::CheckBox, "Notify").focus(); + h.run_steps(3); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_ne!(h.state().session.project.render_prefs.notify, notify, "Enter toggles Notify"); +} + +#[test] +fn the_templates_dialog_is_named() { + let mut h = harness(); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "app.templates", json!({"kind": "renderSettings"})).unwrap(); + h.run_steps(4); + assert!(h.query_by_role_and_label(Role::ComboBox, "Settings Name").is_some()); + assert!(h.query_by_role_and_label(Role::TextInput, "template name").is_some()); + for name in ["Duplicate", "Delete", " OK "] { + assert_eq!(h.get_by_role_and_label(Role::Button, name).accesskit_node().role(), Role::Button, "{name}"); + } + let defaults = h.query_all_by_role(Role::ComboBox).count(); + assert!(defaults >= 4, "the default slots are drop-downs: {defaults}"); +} + +/// A pick with the mouse leaves no widget with the keyboard focus: egui pauses the application's +/// shortcuts (Space, Ctrl+Z) while one has it (#776's rule for the Project panel). +#[test] +fn a_pick_with_the_mouse_leaves_no_keyboard_focus() { + let mut h = harness(); + click(&mut h, "renderQueue.item.1.twirl"); + click(&mut h, "renderQueue.item.1.renderSettings"); + assert!(h.ctx.memory(|m| m.focused()).is_none(), "opening a drop-down with the mouse takes no keyboard focus"); + let template = h.state().session.project.render_templates.render_settings_list()[0].name.clone(); + h.get_by_label_contains(&template).click(); + h.run_steps(4); + assert!(h.ctx.memory(|m| m.focused()).is_none(), "choosing an entry with the mouse leaves no keyboard focus"); + // The Effects & Presets options menu the same. +} + +/// Enter after a click in the panel belongs to the panel (it opens or applies the row), not to +/// the layer rename the Timeline binds to it. +#[test] +fn enter_after_a_click_opens_the_item_and_does_not_rename_a_layer() { + let mut h = harness(); + h.state_mut().session.execute("layer.newSolid", json!({"name": "Red", "color": "#c04040"})).unwrap(); + h.run_steps(3); + click(&mut h, "renderQueue.item.1.row"); + assert_eq!(h.state().ui.focused, PanelKind::RenderQueue); + key(&mut h, Key::Enter, Modifiers::NONE); + assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).accesskit_node().data().is_expanded(), Some(true), "Enter opens the clicked item"); + assert!(h.state().auto.query("timeline.layer.").iter().all(|e| !e.id.ends_with(".nameEdit")), "no layer rename started"); +} diff --git a/crates/ui-egui/tests/ui_settings_keyboard.rs b/crates/ui-egui/tests/ui_settings_keyboard.rs new file mode 100644 index 000000000..e74ccfd94 --- /dev/null +++ b/crates/ui-egui/tests/ui_settings_keyboard.rs @@ -0,0 +1,113 @@ +//! Settings keyboard operation and accessible names (docs/gaps.md gap 25): the page list is one +//! Tab stop worked with the arrow keys, Home and End; every control on a page has the name its +//! label shows, with its value; Escape still cancels. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use egui::accesskit::{Role, Toggled}; +use egui::{Event, Key, Modifiers}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +fn harness(page: &str) -> Harness<'static, EffectcraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(Session::default())); + h.run_steps(3); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "app.settings", json!({"page": page})).unwrap(); + h.run_steps(4); + h +} + +fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) { + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); + h.step(); + } + h.run_steps(3); +} + +fn focused(h: &Harness<'_, EffectcraftApp>) -> Option<(Role, String)> { + h.root().children_recursive().find(|n| n.is_focused()).map(|n| (n.accesskit_node().role(), n.accesskit_node().label().unwrap_or_default())) +} + +fn titles() -> Vec<&'static str> { + effectcraft_engine::prefs::pages().iter().map(|p| p.title).collect() +} + +#[test] +fn the_page_list_is_one_tab_stop_worked_with_the_arrows() { + let mut h = harness("general"); + let titles = titles(); + let n = titles.len(); + // Every page is a list item: its place in the list, and the open page selected. + for (i, title) in titles.iter().enumerate() { + let item = h.get_by_role_and_label(Role::ListItem, title); + assert_eq!(item.accesskit_node().role(), Role::ListItem, "{title}"); + assert_eq!(item.accesskit_node().data().position_in_set(), Some(i + 1)); + assert_eq!(item.accesskit_node().data().size_of_set(), Some(n)); + assert_eq!(item.accesskit_node().is_selected(), Some(i == 0), "{title}"); + } + h.get_by_role_and_label(Role::ListItem, titles[0]).focus(); + h.run_steps(3); + assert_eq!(focused(&h), Some((Role::ListItem, titles[0].to_string()))); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(focused(&h), Some((Role::ListItem, titles[1].to_string())), "Down Arrow opens the next page and the focus goes with it"); + assert_eq!(h.get_by_role_and_label(Role::ListItem, titles[1]).accesskit_node().is_selected(), Some(true)); + assert_eq!(h.get_by_role_and_label(Role::ListItem, titles[0]).accesskit_node().is_selected(), Some(false)); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(titles[0].to_string())); + key(&mut h, Key::ArrowUp, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(titles[0].to_string()), "Up Arrow stops at the first page"); + key(&mut h, Key::End, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(titles[n - 1].to_string()), "End goes to the last page"); + key(&mut h, Key::Home, Modifiers::NONE); + assert_eq!(focused(&h).map(|f| f.1), Some(titles[0].to_string()), "Home goes to the first page"); + // Only the open page is a Tab stop: Tab leaves the list for the page's first control. + key(&mut h, Key::Tab, Modifiers::NONE); + let (role, _) = focused(&h).expect("Tab moves the focus on"); + assert_ne!(role, Role::ListItem, "Tab leaves the page list"); +} + +#[test] +fn controls_have_the_name_their_label_shows_and_their_value() { + let h = harness("general"); + let undo = h.get_by_label("Levels of Undo"); + assert_eq!(undo.accesskit_node().role(), Role::SpinButton); + assert!(undo.accesskit_node().numeric_value().is_some(), "a spin button carries its number"); + let language = h.get_by_label("Language"); + assert_eq!(language.accesskit_node().role(), Role::ComboBox); + assert!(language.accesskit_node().value().is_some_and(|v| !v.is_empty()), "a drop-down carries what it is set to"); + let tips = h.get_by_label("Show Tool Tips"); + assert_eq!(tips.accesskit_node().role(), Role::CheckBox); + assert!(matches!(tips.accesskit_node().toggled(), Some(Toggled::True | Toggled::False))); + for name in [" OK ", "Cancel", "Previous", "Next"] { + assert_eq!(h.get_by_label(name).accesskit_node().role(), Role::Button, "{name}"); + } + // Previous is disabled on the first page. + assert!(h.get_by_label("Previous").accesskit_node().is_disabled()); +} + +#[test] +fn text_fields_colour_buttons_and_theme_cards_are_named() { + let h = harness("labels"); + let name = h.get_by_label("Label 1 name"); + assert_eq!(name.accesskit_node().role(), Role::TextInput); + assert!(name.accesskit_node().value().is_some_and(|v| !v.is_empty()), "the field carries its text"); + let color = h.get_by_label("Label 1 color"); + assert_eq!(color.accesskit_node().role(), Role::ColorWell); + assert!(color.accesskit_node().value().is_some_and(|v| v.starts_with('#')), "a colour carries its hex value"); + let h = harness("appearance"); + assert_eq!(h.get_by_label("Appearance Mode").accesskit_node().role(), Role::ComboBox); + assert!(h.query_all_by_role(Role::RadioButton).count() >= 2, "the themes are radio buttons"); +} + +#[test] +fn escape_still_cancels_with_the_keyboard_on_the_page_list() { + let mut h = harness("general"); + let first = titles()[0]; + h.get_by_role_and_label(Role::ListItem, first).focus(); + h.run_steps(3); + key(&mut h, Key::Escape, Modifiers::NONE); + assert!(h.state().dialog.is_none(), "Escape closes the dialog"); +} diff --git a/docs/gaps.md b/docs/gaps.md index 6851fddc1..ab691dfff 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where EffectCraft falls short -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (path-editing layer bounds, #1220; issue, PR and catalog references gap 21: percentage guides and effect colour labels in; earlier change: re-audited against `upstream/main` 470ea7a3; ranked gap list from the 10 October re-measure otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-30 · **Change:** minor (gap 4 closed: every pull request and push to `main` runs `cargo xtask ci` in CI, #2204; earlier change: gap 25: an AccessKit audit test and a theme contrast test, Effects & Presets, Settings and Render Queue named and keyboard-operable, #1335; earlier change: path-editing layer bounds, #1220; issue, PR and catalog references gap 21: percentage guides and effect colour labels in; earlier change: re-audited against `upstream/main` 470ea7a3; ranked gap list from the 10 October re-measure otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89) An honest assessment of how far EffectCraft is from being a real replacement for After Effects, and the work that closes the gap. This document is meant for contributors and agents choosing @@ -38,7 +38,7 @@ because 179 issues were closed with regression tests between 5 and 10 October. | File formats | ≈ 50% | 130–230 | [file-format-parity.md](file-format-parity.md) | | Hardware | ≈ 40% | 60–120 | [hardware-parity.md](hardware-parity.md) | | Localization | ≈ 24% | 60–110 + review | [localization-parity.md](localization-parity.md) | -| Stability | ≈ 45% | 40–80 | 23 never-crash PRs landed on 4 October and the "Never crash" rules bind every crate, but crash reports are open (#580 Chromatic Aberrations pan/tilt, #501 crash on startup, #426 timeline drag while playing, #341 quit on Intel Mac, #234 macOS 27 exit, #879 `--gpu` export aborts on a failed allocation; #591, #306 and #511 are closed), and no CI workflow runs `cargo xtask ci` on pull requests | +| Stability | ≈ 45% | 40–80 | 23 never-crash PRs landed on 4 October and the "Never crash" rules bind every crate, but crash reports are open (#580 Chromatic Aberrations pan/tilt, #501 crash on startup, #426 timeline drag while playing, #341 quit on Intel Mac, #234 macOS 27 exit, #879 `--gpu` export aborts on a failed allocation; #591, #306 and #511 are closed); `cargo xtask ci` runs on every pull request and push to `main` (#2204) | | Real-user experience | ≈ 60%, uneven by platform | (in G2) | 179 issues closed 5–10 October (as counted on 10 October), nearly all with regression tests ([log below](#real-user-report-log-510-october)); 216 open and 328 closed on 12 October, many from Linux and Windows. Most checking still happens on macOS | | Performance | unknown against After Effects, ≈ 35% | 50–100 | Internal numbers only (e.g. Advanced 3D 290 ms/frame at 1080p on the GPU on an M4 Pro). Users report stutter on cached frames (#595, #304; the RAM preview now prefetches nearest the playhead, 271000b2), late audio (#504, #335); slow scrubbing (#329, closed by #784) and #554 are closed. No 4K / hundreds-of-layers project tested | | Media formats | ≈ 75% | (in file formats) | H.264, ProRes, HEVC, AV1, VP9, image sequences and audio exist. The HEVC / AV1 encoders have no B-frames, multi-reference or SAO / CDEF, so files are larger than from mature encoders. Camera formats (BRAW, R3D, ProRes RAW, variable-frame-rate phone video) are unverified | @@ -60,7 +60,7 @@ are grouped by area; the issue tracker has the rest. | 1 | **`.aep` / `.aepx` projects can't be opened** | No reader; candidate route py-aep (#248, closed as a duplicate of #190; MIT, external converter) awaits an owner decision on clean-room scope; #190; open PRs #251 (native decoder) and #860 (external py-aep converter) | The biggest barrier to switching: existing work, templates and client projects stay in After Effects | 80–160 h | G4 · [file-format-parity.md](file-format-parity.md) | | 2 | **Fidelity unmeasured** beyond scalar ease | [fidelity/](fidelity/README.md): 168 samples, one property type; `compare_frames` helper exists, no corpus | Every "done" feature may still differ; users find the differences | 60–100 h harness + P0/P1 corpus; more per feature | G1 · [target-app-parity.md](target-app-parity.md) | | 3 | **Open crash reports** | Open: #580, #501, #426, #341, #234, #879. Now closed: #591, #306, #511 | A crash loses work; outranks features ([AGENTS.md](../AGENTS.md)) | 10–25 h | G3 · this file | -| 4 | **No CI gate on pull requests** | `.github/workflows` has release, packaging-lint, FreeBSD and Windows ARM64 jobs only; `cargo xtask ci` runs locally | Regressions reach main unseen; 49 community PRs landed in one batch (#598) | 4–8 h | G3 · this file | +| 4 | ~~**No CI gate on pull requests**~~ closed | `.github/workflows/ci.yml` runs `cargo xtask ci` (fmt, clippy, tests, layers, assets, wasm) on every pull request and push to `main` through craft-common-actions' shared `gate.yml` (#2204) | — | done | G3 · this file | | 5 | **Playback and scrubbing feel** | #595 stutter on cached frames (partly addressed: nearest-first RAM preview prefetch, cache bars at 1/3 resolution), #304, #504 / #335 audio late or crackling, #586 puppet lag (maintainers asked for details); #329 slow scrubbing (#784) and #554 closed | Preview is where motion designers spend their day | 25–50 h | G5 · [hardware-parity.md](hardware-parity.md) | | 6 | **Linux and Windows interaction bugs** | Open: #488 rename arrows (hardening landed, reporter asked for details), #492 anchor drag, #268 file drops on Wayland (winit), #613 Direct3D 12 compositor. Now closed: #491, #493, #467, #320, #302, #596 | Many reports come from Linux; some Windows GPUs fail to start the compositor | 25–45 h | G2 · [ui-parity.md](ui-parity.md) | | 7 | **Animation presets: 76 against 621** | After Effects ships 621 `.ffx` presets (Behaviors, Backgrounds, Shapes, Synthetics, Text, Transitions…); we ship 76 original presets (8 text-animator; 23 Transitions: wipes, clock and fan reveals, blinds, dissolves, card flips, CC wipes, fades, #757; 23 Backgrounds: drifting gradients, fog, cloud and smoke, plasma and magma, cells, hexagons, scrolling grids and checks, pulse rings, bokeh, light sweep, #1096, driven by expressions so they keep moving, not seamless loops; 22 Behaviors: wiggle, shake, float, orbit, bounce, drift, pendulum, spin, pulse, heartbeat, blink, strobe, flicker on transform and opacity, #1510) and read only our `.ecpreset`. Missing: Shapes, Synthetics, Image and more Text | Presets are how many designers start; tutorials assume them | 15–35 h (original presets; `.ffx` reading is out of clean-room scope) | G8 · [effects-parity.md](effects-parity.md) | @@ -81,7 +81,7 @@ are grouped by area; the issue tracker has the rest. | 22 | **Encoder efficiency** | HEVC / AV1 have no B-frames, multi-reference or SAO / CDEF; Opus no FEC / DTX; single-file intermediates are 8-bit uncompressed QuickTime only (#372 closed by #778: no 16-bpc, AVI or lossless-compressed codec); #325 render over existing file | Larger files than After Effects + Media Encoder | 20–40 h | G6 · [file-format-parity.md](file-format-parity.md) | | 23 | **Essential Graphics interchange** | Templates are `.ectemplate`, not `.mogrt`; #338 and #611 closed | Premiere editors can't use our templates | 15–30 h | — · [file-format-parity.md](file-format-parity.md) | | 24 | **Timeline depth requests** | #601 expand precomps in place, #602 reorderable render stack, #604 guide-only effects, #425 keyframe confusion (#355 and #428 closed) | Power-user speed | 15–30 h | G2 · [ui-parity.md](ui-parity.md) | -| 25 | **Accessibility** | AccessKit is enabled in eframe and tests read focus labels; the Project panel has After Effects' arrow-key navigation and named, stateful AccessKit nodes (#776), but the other panels have had no screen-reader pass or keyboard navigation audit, and there is no contrast check | Excludes users; After Effects supports screen readers poorly too | 15–30 h | G9 · [ui-parity.md](ui-parity.md) | +| 25 | **Accessibility** | AccessKit is enabled in eframe. `ui_accessibility_audit` walks the AccessKit tree and counts unnamed or roleless controls per panel; Project (#776), Effects & Presets, Settings (every page) and the Render Queue with its Templates dialog are at zero and held there, with named, stateful nodes and arrow-key, Home / End, Enter and Space operation of their lists (#1335). The other 30 or so panels are counted but not fixed (the Timeline alone has about 55 unnamed controls; the baseline in the test shows each). `ui_contrast` checks the theme tokens against WCAG 2: 13 of 48 pairs fall short (primary-button text, error text, control borders, the whole Light theme's warning colour). **No screen reader has been run on any panel**: the tree is checked, not how NVDA, VoiceOver or Orca speak it. Not covered: keyboard-only operation of the other panels, focus order across panels, colour-blindness, text size, reduced motion. App shortcuts pause while any widget has keyboard focus (egui), so Ctrl+Z does nothing with a list row focused | Excludes users; After Effects supports screen readers poorly too | 12–25 h (first slice, about 3 h, done) | G9 · [ui-parity.md](ui-parity.md) | ## Where we're going: workstreams in priority order @@ -235,7 +235,7 @@ The most important missing piece: it turns every other estimate here into a meas Kept from the 5 October assessment's "Real-user experience" row, as evidence of what users hit and what was fixed: -Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the viewer snaps back, panels can't be resized or rearranged, drag-and-drop and double-click import don't work, layer rename gets stuck, the Layer Settings arrow does nothing, the Project panel clips, the Wayland window icon is generic. Issues #63–#68 (macOS, 5 October): scaling a layer by its handles goes wrong and can stick at 0, two 3D compasses, the viewer lags while a layer is dragged (a frame renders in 20 ms but showed after ≈ 100 ms, behind RAM preview prefetch), the Discord and other links do nothing, Delete doesn't delete in the Project panel, hidden layers can be selected in the viewer. All but the last two were fixed or confirmed fixed with a test on 5–6 October (those two have community PRs); file drops still can't work on Wayland (winit has no support). The panning, rename and arrow bugs had been fixed in v0.2.0 already; nobody had told the reporter. A Windows user (6 October, on Discord) found the font menus listed only the three bundled fonts: installed fonts were read only when a project asked for one, on every platform. Fixed with a regression test on 6 October; the menus now list every installed family and its own styles, and agents get `text.fonts` / `list_fonts`. Seven reports of 6 October (Linux .deb and Windows): Project items and files couldn't be dropped on the viewer (#85), nor effects on a layer there (#88); drops in the Timeline ignored where they landed (#89); an angle's revolutions couldn't be edited (#93); toggling Audio, Lock or Shy threw away the RAM preview and playback with audio skipped frames (#103); running out of video memory panicked every frame thread and left frames stuck (#106); choosing a Render Settings template in the Render Queue did nothing (#117: any popup menu moved up to fit the window closed on the press). All seven were fixed with regression tests on 7 October. Reports of 7 October (Linux, macOS, Windows): the app opened the demo project on every launch, so a new comp landed in it (#204); a comp wider than the GPU's texture limit (11000 × 2200) at Full resolution stopped the window drawing (#201); an Intel HD Graphics 5500 couldn't start the app (#198: most likely the window's device asked for limits that GPU doesn't have, and the failure was silent); Audio Spectrum / Waveform stood still in the viewer (#209) and preview sound waited for the whole work area to cache (#208); a layer couldn't be deselected from the time graph, a shape layer's Fill and Stroke showed only with a shape tool and a drawn shape had no stroke to change, Alt+Shift+P didn't reveal Position (#205); a mask selected in the Timeline wasn't selected in the viewer, Mask Feather had no link and took negative values, the Project panel had no selection box (#203); shape layers had no Add menu for Trim Paths and the other operations, and Classic 3D comps hid Geometry Options (#206); dragging Puppet pins re-rendered the layer under the Puppet at every move (#212). All were fixed with regression tests the same day (#219–#222); the #198 fix couldn't be checked on that GPU. Reports of 8 October (browser, Windows, macOS): an empty project at 1280 × 800 with device pixel ratio 2 crashed on its first frame, because the Project panel's scroll bar clamped its thumb to a track shorter than the thumb (#231, any short panel on every platform); Media Browser ▸ Add Files… dropped a file it couldn't read without a word (#226); after the page's WebGPU device was lost, the browser editor froze on its last frame while edits went on (#225); a press in the viewer always took the topmost layer, so a selected layer behind others couldn't be dragged (#230); the language didn't follow the system's (#229); and a list of missing After Effects behaviour (#227): drawing into, copying, pasting and duplicating shape contents, Tool Creates Shape / Mask and Fill / Stroke Options, Turbulent Displace, Wave Warp and Bulge pinning all but a shape layer's bottom-right quadrant, a Spacebar hold that previewed instead of being the Hand tool, the Effect menu in Effect Controls, dropping items on New Composition and dragging over layer switches. All were fixed with regression tests the same day (#235, #237–#241); multi-monitor support and the Project panel's empty-area menu (community PR #210) remain from #227, and effect points on shape and text layers still sit half a comp off. A list of tweaks from an Intel iMac (#290): the layer bar didn't show where extended footage runs past its source, and Enable Time Remapping then ended at the extended out point; footage items had no Interpret Footage in their menu and the work area bar no menu; Label menus named the labels without their colours; the arrow keys didn't nudge layers; a straight motion path had no Bezier handles; Delete on Time Remap deleted the layer; a mask being drawn with the Pen blacked out its layer until it was closed; a double-click on a mask point didn't select the whole mask. All were fixed with regression tests the same day; the rest of #290 had been fixed already (#221, #238, #255) or came with community PRs #210 (the Timeline's empty-area menu) and #213 (tool flyouts on a long press). A report of 9 October (#397): CC Particle World ignored the comp camera and looked flat. It now sees its particles through an active camera layer, as After Effects does, and through its own Extras ▸ Effect Camera (Distance, Rotation X / Y / Z, FOV, added) otherwise; the layer cache also missed camera and light moves for the effects that read them (Card Dance, Shatter, Card Wipe, Caustics). Fixed with regression tests the same day. Most checking happens on macOS. Localisation covers the menus in Japanese, Simplified and Traditional Chinese, and Ukrainian, and since 9 October the cataloged panels, dialogs and tooltips too (Simplified and Traditional Chinese, and Ukrainian; the Japanese rows of the panel catalog are still open); no accessibility work +Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the viewer snaps back, panels can't be resized or rearranged, drag-and-drop and double-click import don't work, layer rename gets stuck, the Layer Settings arrow does nothing, the Project panel clips, the Wayland window icon is generic. Issues #63–#68 (macOS, 5 October): scaling a layer by its handles goes wrong and can stick at 0, two 3D compasses, the viewer lags while a layer is dragged (a frame renders in 20 ms but showed after ≈ 100 ms, behind RAM preview prefetch), the Discord and other links do nothing, Delete doesn't delete in the Project panel, hidden layers can be selected in the viewer. All but the last two were fixed or confirmed fixed with a test on 5–6 October (those two have community PRs); file drops still can't work on Wayland (winit has no support). The panning, rename and arrow bugs had been fixed in v0.2.0 already; nobody had told the reporter. A Windows user (6 October, on Discord) found the font menus listed only the three bundled fonts: installed fonts were read only when a project asked for one, on every platform. Fixed with a regression test on 6 October; the menus now list every installed family and its own styles, and agents get `text.fonts` / `list_fonts`. Seven reports of 6 October (Linux .deb and Windows): Project items and files couldn't be dropped on the viewer (#85), nor effects on a layer there (#88); drops in the Timeline ignored where they landed (#89); an angle's revolutions couldn't be edited (#93); toggling Audio, Lock or Shy threw away the RAM preview and playback with audio skipped frames (#103); running out of video memory panicked every frame thread and left frames stuck (#106); choosing a Render Settings template in the Render Queue did nothing (#117: any popup menu moved up to fit the window closed on the press). All seven were fixed with regression tests on 7 October. Reports of 7 October (Linux, macOS, Windows): the app opened the demo project on every launch, so a new comp landed in it (#204); a comp wider than the GPU's texture limit (11000 × 2200) at Full resolution stopped the window drawing (#201); an Intel HD Graphics 5500 couldn't start the app (#198: most likely the window's device asked for limits that GPU doesn't have, and the failure was silent); Audio Spectrum / Waveform stood still in the viewer (#209) and preview sound waited for the whole work area to cache (#208); a layer couldn't be deselected from the time graph, a shape layer's Fill and Stroke showed only with a shape tool and a drawn shape had no stroke to change, Alt+Shift+P didn't reveal Position (#205); a mask selected in the Timeline wasn't selected in the viewer, Mask Feather had no link and took negative values, the Project panel had no selection box (#203); shape layers had no Add menu for Trim Paths and the other operations, and Classic 3D comps hid Geometry Options (#206); dragging Puppet pins re-rendered the layer under the Puppet at every move (#212). All were fixed with regression tests the same day (#219–#222); the #198 fix couldn't be checked on that GPU. Reports of 8 October (browser, Windows, macOS): an empty project at 1280 × 800 with device pixel ratio 2 crashed on its first frame, because the Project panel's scroll bar clamped its thumb to a track shorter than the thumb (#231, any short panel on every platform); Media Browser ▸ Add Files… dropped a file it couldn't read without a word (#226); after the page's WebGPU device was lost, the browser editor froze on its last frame while edits went on (#225); a press in the viewer always took the topmost layer, so a selected layer behind others couldn't be dragged (#230); the language didn't follow the system's (#229); and a list of missing After Effects behaviour (#227): drawing into, copying, pasting and duplicating shape contents, Tool Creates Shape / Mask and Fill / Stroke Options, Turbulent Displace, Wave Warp and Bulge pinning all but a shape layer's bottom-right quadrant, a Spacebar hold that previewed instead of being the Hand tool, the Effect menu in Effect Controls, dropping items on New Composition and dragging over layer switches. All were fixed with regression tests the same day (#235, #237–#241); multi-monitor support and the Project panel's empty-area menu (community PR #210) remain from #227, and effect points on shape and text layers still sit half a comp off. A list of tweaks from an Intel iMac (#290): the layer bar didn't show where extended footage runs past its source, and Enable Time Remapping then ended at the extended out point; footage items had no Interpret Footage in their menu and the work area bar no menu; Label menus named the labels without their colours; the arrow keys didn't nudge layers; a straight motion path had no Bezier handles; Delete on Time Remap deleted the layer; a mask being drawn with the Pen blacked out its layer until it was closed; a double-click on a mask point didn't select the whole mask. All were fixed with regression tests the same day; the rest of #290 had been fixed already (#221, #238, #255) or came with community PRs #210 (the Timeline's empty-area menu) and #213 (tool flyouts on a long press). A report of 9 October (#397): CC Particle World ignored the comp camera and looked flat. It now sees its particles through an active camera layer, as After Effects does, and through its own Extras ▸ Effect Camera (Distance, Rotation X / Y / Z, FOV, added) otherwise; the layer cache also missed camera and light moves for the effects that read them (Card Dance, Shatter, Card Wipe, Caustics). Fixed with regression tests the same day. Most checking happens on macOS. Localisation covers the menus in Japanese, Simplified and Traditional Chinese, and Ukrainian, and since 9 October the cataloged panels, dialogs and tooltips too (Simplified and Traditional Chinese, and Ukrainian; the Japanese rows of the panel catalog are still open); accessibility is gap 25 ### G3. Stability and a green main @@ -249,7 +249,7 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie runs via `EFFECTCRAFT_FUZZ_ITERS` / `EFFECTCRAFT_FUZZ_SECS`). It found one crash, deeply nested XML overflowing the stack, now refused. Imported media and documents, expressions, scripts and control-channel / MCP requests are still to do. -- Run the gate on every pull request in CI (gap 4): today no workflow runs the tests on PRs. +- Keep the CI gate (gap 4, #2204) the same as `cargo xtask ci`: it runs on every pull request and push to `main`. - Fix the open crash reports first (gap 3). Further fuzzing and crash fixes are open in PR #855. - Done when: the gate is green on stable, runs on every pull request, and the fuzzers run regularly without new crashes. @@ -350,6 +350,7 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie | Date | Change | Summary | |---|---|---| | 2026-10-16 | minor | Gap 8: #932 closed (Convert To Bezier Path converts a shape layer's parametric path, not only mask RotoBezier) | +| 2026-10-13 | minor | Gap 25: an AccessKit audit test (per-panel counts, a ratchet for the unfixed panels) and a theme contrast test; Effects & Presets, Settings and Render Queue named and keyboard-operable (#1335); no screen reader tested | | 2026-10-13 | patch | G2: layer bounds no longer compete with editable shape/mask path points (#1220) | | 2026-10-12 | minor | Audited against `upstream/main` 470ea7a3: open-issue count and crash list, closed issues removed from rows 1, 3, 5, 6, 8–12, 15–17, 22–24, G8 and G9 (#759 `#include`), localization catalogs recounted (≈ 12% to ≈ 18%); rows 7, 14 and 21 left to open PRs #1102, #792 and #1098 | | 2026-10-10 | minor | Alpha gate checked: no alpha blockers | diff --git a/docs/mcp.md b/docs/mcp.md index 7685a66e2..686adcb35 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -12,8 +12,8 @@ written only under a write root; with no write root nothing is written. Every en parameters must be absolute paths inside the roots (symbolic links resolved, no `..`), and so must any other absolute path in its parameters. Save, Increment and Save and rendering the Render Queue are checked against the files they write (the open project's, the queued outputs); -`renderQueue.add` without `output` needs a saved project, since an unsaved one's default output -would go to the home folder (#2479). Scripts +`renderQueue.add`, `render.preRender` and `file.createProxy` without an output file need a saved +project, since an unsaved one's default output would go to the home folder (#2479). Scripts (`run_script`, `file.runScript`…), Execute File, Reveal in Finder, `$N` references to file paths in a batch, and the bridge's UI-driving tools (`ui_click`, `ui_key`, `ui_type`, `ui_drag`, `ui_set`, `script_ui`, `control`) are refused while confined. Without the flags nothing changes. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d4473c266..cbb936271 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-18 · **Change:** minor (shape group Contents row elided; path-editing layer bounds #1220; closed issue references re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-18 · **Change:** minor (accessibility row: an AccessKit audit and a contrast test, three more panels named and keyboard-operable, #1335; earlier changes: shape group Contents row elided; path-editing layer bounds #1220; closed issue references re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026 How closely EffectCraft's interaction matches After Effects: tools, handles, snapping, nudging, modifier keys, shortcuts, drag behaviour, numeric entry, panels, context menus and feel. Evidence @@ -30,7 +30,7 @@ feel and platform differences, and the 26.x interface changes are not in yet. | Preview feel | Spacebar / Numpad 0 previews, RAM preview, cache bars, adaptive resolution | 55% | `ui_ram_preview`, `ui_live_preview`; stutter (#595, #304); scrubbing lag (#329, closed by #784) | | Home screen, Learn, templates | Home with recent projects, template gallery, interactive Learn tutorials | 70% | Layout nitpicks (#581); Home literals untranslated (#574) | | Quick Apply (26.2) | Search to apply effects, presets or commands | partial | A command palette exists; not After Effects' Quick Apply | -| Accessibility | AccessKit enabled; focus labels tested; Project panel keyboard navigation and accessible names (#776) | 20% | No screen-reader pass or keyboard-navigation audit of the other panels; no contrast check | +| Accessibility | AccessKit enabled; `ui_accessibility_audit` counts unnamed or roleless controls per panel: Project (#776), Effects & Presets, Settings (all pages) and Render Queue with Templates are at zero, with names, roles, selected / open / checked state and arrow-key, Home / End, Enter, Space operation (#1335); `ui_contrast` checks theme tokens against WCAG 2 | 20% | No screen reader has been run on anything; the other panels are counted, not fixed (Timeline ≈ 55 unnamed controls); 13 of 48 token pairs are below WCAG 2 (primary button text, error text, control borders, Light theme warning colour); focus order across panels, colour-blindness and reduced motion are unchecked; app shortcuts pause while a widget has keyboard focus | | UI scale | Settings ▸ Appearance ▸ UI Scale 75–200% | beyond | After Effects follows the system's scaling (#284) | Path editing (#1220): the Composition viewer suppresses a selected layer’s transform box while @@ -42,7 +42,7 @@ cover shape paths and masks, corner-vertex dragging and undo, and layers without ## Estimate **≈ 60–110 h**: the open interaction issues above (25–45 h, about 0.2–0.5 h each with a scripted -test), 3D gizmos 8–15 h, the 26.x interface changes 10–20 h, an accessibility pass 15–30 h. Checking feel against After Effects needs the owner's Mac with After +test), 3D gizmos 8–15 h, the 26.x interface changes 10–20 h, an accessibility pass 12–25 h (the first slice, three panels and the audit, is done). Checking feel against After Effects needs the owner's Mac with After Effects running; most of the rest parallelises. ## Revision history @@ -50,6 +50,7 @@ Effects running; most of the rest parallelises. | Date | Change | Summary | |---|---|---| | 2026-10-18 | minor | A shape group's inner Contents (After Effects' elided Vectors group) draws no row: the group's path, fill/stroke and Transform list directly under the group | +| 2026-10-13 | minor | Accessibility row: AccessKit audit and contrast tests, Effects & Presets, Settings and Render Queue named and keyboard-operable (#1335); no screen reader tested | | 2026-10-13 | patch | Layer bounds are suppressed during path-point editing (#1220); shape/mask interaction regressions | | 2026-10-12 | minor | Closed issues (#302, #320, #491, #511, #553 and others) marked as closed instead of open; panels in their own windows (#486) | | 2026-10-12 | minor | Percentage guides and effect colour labels (26.5) added; rows updated |