mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-02 14:38:21 -06:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
Generated
+3
-2
@@ -1121,13 +1121,14 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
"egui",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"semver",
|
||||
@@ -4293,7 +4294,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+2
-1
@@ -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" }
|
||||
|
||||
@@ -155,7 +155,7 @@ fn install_file_dialogs(app: &mut EffectcraftApp) -> Option<std::sync::mpsc::Rec
|
||||
fn main() -> eframe::Result {
|
||||
// Windows: the update helper is a copy of this program started with `--finish-update`.
|
||||
#[cfg(windows)]
|
||||
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::EFFECTCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Some(result) = craft_update::self_update::finish(&effectcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
std::process::exit(i32::from(result.is_err()));
|
||||
}
|
||||
// First, so the panic hook and every start-up warning are recorded. Records go to stderr
|
||||
|
||||
@@ -114,3 +114,6 @@ sync_github_name = false
|
||||
|
||||
[people.ashokram-si]
|
||||
real_name = "Ashok Ram"
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -4,7 +4,7 @@ use effectcraft_engine::Session;
|
||||
use effectcraft_engine::time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::confine::Confinement;
|
||||
use crate::confine::{Confinement, Lookup};
|
||||
use crate::{BridgeClient, Error, Result, base64, encode_png, recompress_png};
|
||||
|
||||
pub struct Backend {
|
||||
@@ -63,12 +63,11 @@ impl Backend {
|
||||
pub fn exec(&mut self, id: &str, params: Value) -> Result<Value> {
|
||||
let params = if params.is_null() { json!({}) } else { params };
|
||||
if let Some(c) = self.confinement.clone() {
|
||||
let lookup = match c.check_command(id, ¶ms)? {
|
||||
Some(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)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
//! `engine.batch`) is checked before it runs: its file parameters must be absolute paths inside
|
||||
//! the roots (symbolic links resolved, no `..`), any other absolute path in its parameters too,
|
||||
//! and commands that write to a file they are not given (Save, Increment and Save, rendering the
|
||||
//! Render Queue) are checked against where they would write. Commands that run code or open
|
||||
//! other applications (scripts, ScriptUI panels and the events that reach them, Execute File,
|
||||
//! Reveal in Finder…) are refused while confined, as is granting scripts the file and network
|
||||
//! access the user withheld.
|
||||
//! Render Queue) are checked against where they would write. Queueing a render without naming
|
||||
//! its output needs a saved project, because the default output of an unsaved one is the home
|
||||
//! folder. Commands that run code or open other applications (scripts, ScriptUI panels and the
|
||||
//! events that reach them, Execute File, Reveal in Finder…) are refused while confined, as is
|
||||
//! granting scripts the file and network access the user withheld.
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
@@ -121,6 +122,9 @@ const WRITES: &[(&str, &[&str])] = &[
|
||||
/// Commands that write where they are not told: the project's file, the Render Queue's outputs.
|
||||
const IMPLICIT_WRITES: &[&str] = &["file.save", "file.incrementAndSave", "renderQueue.render", "render.preRender"];
|
||||
|
||||
/// Commands that queue a render, whose output defaults to the project's folder when not given.
|
||||
const QUEUES_AN_OUTPUT: &[&str] = &["renderQueue.add", "render.preRender", "file.createProxy"];
|
||||
|
||||
/// What must be looked up before an implicitly writing command may run.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Lookup {
|
||||
@@ -128,8 +132,9 @@ pub enum Lookup {
|
||||
ProjectFile,
|
||||
/// Every output of the queued Render Queue items (`renderQueue.list`).
|
||||
RenderOutputs,
|
||||
/// Whether the open project has a file (`project.summary` → `path`): a Render Queue item
|
||||
/// added without `output` writes beside it, or to the home folder before the first save.
|
||||
/// Whether the open project has a file (`project.summary` → `path`): a render queued without
|
||||
/// `output` (Render Queue, Pre-render, Create Proxy) writes beside it, or to the home folder
|
||||
/// before the first save.
|
||||
QueueDefault,
|
||||
}
|
||||
|
||||
@@ -169,7 +174,7 @@ impl Confinement {
|
||||
|
||||
/// Check engine command `id` with `params` before it runs. Returns what must still be looked
|
||||
/// up and passed to [`Confinement::check_lookup`] for commands that write implicitly.
|
||||
pub fn check_command(&self, id: &str, params: &Value) -> Result<Option<Lookup>> {
|
||||
pub fn check_command(&self, id: &str, params: &Value) -> Result<Vec<Lookup>> {
|
||||
if REFUSED.contains(&id) || effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id) {
|
||||
return Err(Error::BadArgs(format!("`{id}` is not available while file access is confined (--automation-read-root/--automation-write-root)")));
|
||||
}
|
||||
@@ -192,11 +197,11 @@ impl Confinement {
|
||||
if named.iter().chain(mixed).filter_map(|k| inner_params.get(*k)).any(has_reference) {
|
||||
return Err(Error::BadArgs(format!("`{inner}` in a batch: file paths can't be `$N` references while file access is confined")));
|
||||
}
|
||||
if self.check_command(inner, &inner_params)?.is_some() {
|
||||
if !self.check_command(inner, &inner_params)?.is_empty() {
|
||||
return Err(Error::BadArgs(format!("`{inner}` writes to its project's or queue's files: run it on its own while file access is confined")));
|
||||
}
|
||||
}
|
||||
return Ok(None);
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let (named, access) = match (keys_of(WRITES, id), keys_of(READS, id)) {
|
||||
(Some(k), _) => (k, Access::Write),
|
||||
@@ -205,6 +210,10 @@ impl Confinement {
|
||||
(None, None) => (&[][..], Access::Read),
|
||||
};
|
||||
let mut given = false;
|
||||
// Whether one of them is a file: not a Render Queue template, whose own output is relative
|
||||
// and resolved like one the agent never gave, and not an array, which the commands that
|
||||
// take a single output read as nothing at all.
|
||||
let mut gave_a_file = false;
|
||||
for key in named {
|
||||
let Some(v) = params.get(*key).filter(|v| !v.is_null()) else { continue };
|
||||
given = true;
|
||||
@@ -217,6 +226,7 @@ impl Confinement {
|
||||
match p.as_str() {
|
||||
Some(s) if matches!(id, "render.preRender" | "file.createProxy") && !looks_like_path(s) => {}
|
||||
Some(s) => {
|
||||
gave_a_file |= v.is_string();
|
||||
self.check(s, access)?;
|
||||
}
|
||||
None => return Err(Error::BadArgs(format!("`{id}`: `{key}` must be a path"))),
|
||||
@@ -242,14 +252,19 @@ impl Confinement {
|
||||
if matches!(id, "file.openRecent" | "file.importRecent" | "anim.applyRecentPreset") && !given {
|
||||
return Err(Error::BadArgs(format!("`{id}`: give `path` while file access is confined")));
|
||||
}
|
||||
// Without `output`, an unsaved project's item would write to the home folder (#2479).
|
||||
if id == "renderQueue.add" && !given {
|
||||
return Ok(Some(Lookup::QueueDefault));
|
||||
let mut lookups = vec![];
|
||||
// An output the agent did not name is derived from where the project sits: the Default
|
||||
// Output Folder preference when that is set, beside the project file otherwise — and the
|
||||
// home folder when the project has never been saved, outside every root (#2479). Only
|
||||
// that last case is certainly outside, and reading the preference here would duplicate
|
||||
// the engine's precedence, so an unsaved project is told to name its output instead.
|
||||
if QUEUES_AN_OUTPUT.contains(&id) && !gave_a_file {
|
||||
lookups.push(Lookup::QueueDefault);
|
||||
}
|
||||
if IMPLICIT_WRITES.contains(&id) && !(given && id == "file.save") {
|
||||
return Ok(Some(if id == "file.save" || id == "file.incrementAndSave" { Lookup::ProjectFile } else { Lookup::RenderOutputs }));
|
||||
lookups.push(if id == "file.save" || id == "file.incrementAndSave" { Lookup::ProjectFile } else { Lookup::RenderOutputs });
|
||||
}
|
||||
Ok(None)
|
||||
Ok(lookups)
|
||||
}
|
||||
|
||||
/// Check what a [`Lookup`] found: `project.summary` or `renderQueue.list`'s result.
|
||||
@@ -264,7 +279,7 @@ impl Confinement {
|
||||
Lookup::QueueDefault => match found.get("path").and_then(Value::as_str) {
|
||||
Some(_) => Ok(()),
|
||||
None => Err(Error::BadArgs(
|
||||
"`renderQueue.add`: the project has no file yet, so the default output would go to the home folder: give `output` inside an automation write root".into(),
|
||||
"the project has no file yet, so the default output would go to the home folder: give `output` inside an automation write root, or save the project inside one first".into(),
|
||||
)),
|
||||
},
|
||||
Lookup::RenderOutputs => {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -362,6 +362,22 @@ mod tests {
|
||||
assert!(a > 0.99 && c[0] > 0.5, "{c:?}");
|
||||
}
|
||||
|
||||
/// #2102: negative Choke Matte keeps growing the matte past -20, down to -100: a 120 px hole
|
||||
/// in an opaque frame closes a bit more with every step.
|
||||
#[test]
|
||||
fn simple_choker_grows_the_matte_at_large_negative_values() {
|
||||
let mut img = Image::filled(200, 200, [1.0, 1.0, 1.0, 1.0]);
|
||||
for y in 40..160 {
|
||||
for x in 40..160 {
|
||||
img.set(x, y, [0.0; 4]);
|
||||
}
|
||||
}
|
||||
let filled = |c: f64| alpha_sum(&run("ec.matte.simplechoker", &[("chokeMatte", num(c))], img.clone()));
|
||||
let sums: Vec<f32> = [-10.0, -20.0, -40.0, -60.0].into_iter().map(filled).collect();
|
||||
assert!(sums.windows(2).all(|w| w[1] > w[0] + 100.0), "{sums:?}");
|
||||
assert!((filled(-100.0) - 200.0 * 200.0).abs() < 1.0, "-100 closes the hole");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matte_choker_defaults_shrink() {
|
||||
let img = square();
|
||||
|
||||
@@ -164,7 +164,7 @@ pub use enabled::always;
|
||||
pub(crate) use enabled::{has_comp, has_keys, has_layers, has_project_selection, not_yet, not_yet_run};
|
||||
pub use params::match_path_of;
|
||||
pub(crate) use params::{
|
||||
b_p, bad, comp_id, f_p, frontend, layer_mut, layer_p, layers_p, merge_p, popup_options, resolve_effect_path, resolve_layer, selectable,
|
||||
b_p, bad, comp_id, f_p, frontend, layer_mut, layer_p, layers_or, layers_p, merge_p, popup_options, resolve_effect_path, resolve_layer, selectable,
|
||||
selected_leaf_props, str_p, time_p, unlocked, visible_p,
|
||||
};
|
||||
pub use schema::{accepted_params, check_params, params_schema};
|
||||
|
||||
@@ -56,6 +56,10 @@ fn compatible(a: &Property, b: &effectcraft_keyframe::Value) -> bool {
|
||||
a.value.kind_name() == b.kind_name() && a.value.components().len() == b.components().len()
|
||||
}
|
||||
|
||||
pub(crate) fn locked_selection(comp: &effectcraft_project::Comp, selected: &[KeyRef]) -> Vec<KeyRef> {
|
||||
selected.iter().filter(|k| comp.layer(k.layer).is_some_and(|l| l.switches.locked)).copied().collect()
|
||||
}
|
||||
|
||||
/// Paste copied keyframes at the current time into the target layers' properties.
|
||||
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let clip = s.state.key_clipboard.clone();
|
||||
@@ -90,6 +94,9 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut plan: Vec<(LayerId, Uid, usize)> = vec![];
|
||||
for (ti, lid) in targets.iter().enumerate() {
|
||||
let Some(l) = comp.layer(*lid) else { continue };
|
||||
if l.switches.locked {
|
||||
continue;
|
||||
}
|
||||
for (ci, c) in clip.iter().enumerate() {
|
||||
if paired && sources.get(ti) != Some(&c.layer) {
|
||||
continue;
|
||||
@@ -111,11 +118,12 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
if plan.is_empty() {
|
||||
return Err(bad("keys.paste", "no compatible property to paste into (select a property of the same type)"));
|
||||
return Err(bad("keys.paste", "no compatible unlocked property to paste into (unlock the target or select a property of the same type)"));
|
||||
}
|
||||
s.edit("Paste Keyframes", merge_p(p), |proj, st| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let mut sel = vec![];
|
||||
let mut sel = locked_selection(comp, &st.selected_keys);
|
||||
let retained = sel.len();
|
||||
for (lid, uid, ci) in &plan {
|
||||
let Some(l) = comp.layer_mut(*lid) else { continue };
|
||||
let times: Vec<Tick> = clip[*ci].keys.iter().map(|k| l.layer_time(t + k.time)).collect();
|
||||
@@ -137,8 +145,9 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
retime_roving(&mut pr.keys, pr.spatial);
|
||||
}
|
||||
let pasted = sel.len().saturating_sub(retained);
|
||||
st.selected_keys = sel;
|
||||
Ok(json!(st.selected_keys.len()))
|
||||
Ok(json!(pasted))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -176,6 +185,9 @@ pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, L
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let t = time_p(s, p, Some(comp));
|
||||
let target = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
if target.switches.locked {
|
||||
return Err(bad("keys.paste", "the target layer is locked"));
|
||||
}
|
||||
let mut order: Vec<usize> = (0..path.vertices.len()).collect();
|
||||
if path.closed && !order.is_empty() {
|
||||
order.push(0);
|
||||
@@ -203,6 +215,9 @@ pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, L
|
||||
})
|
||||
.collect();
|
||||
s.edit("Paste Keyframes", merge_p(p), |proj, st| {
|
||||
let comp = proj.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let mut selected = locked_selection(comp, &st.selected_keys);
|
||||
let retained = selected.len();
|
||||
let l = super::layer_mut(proj, cid, lid)?;
|
||||
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.paste", "no property"))?;
|
||||
let mut sel = vec![];
|
||||
@@ -217,8 +232,10 @@ pub(super) fn paste_path(s: &mut Session, p: &Value, (cid, lid, uid): (ItemId, L
|
||||
// Re-timing keeps the keys' order, so the pasted keys are found again by index.
|
||||
let pasted: Vec<usize> = pr.keys.iter().enumerate().filter(|(_, k)| sel.contains(&k.time)).map(|(i, _)| i).collect();
|
||||
retime_roving(&mut pr.keys, pr.spatial);
|
||||
st.selected_keys = pasted.iter().filter_map(|&i| pr.keys.get(i)).map(|k| KeyRef { layer: lid, prop: uid, time: k.time }).collect();
|
||||
Ok(json!(st.selected_keys.len()))
|
||||
selected.extend(pasted.iter().filter_map(|&i| pr.keys.get(i)).map(|k| KeyRef { layer: lid, prop: uid, time: k.time }));
|
||||
let count = selected.len().saturating_sub(retained);
|
||||
st.selected_keys = selected;
|
||||
Ok(json!(count))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -297,6 +314,9 @@ fn set_key(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let base = KeyTimeBase::from_params(p, "keys.set")?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
if layer.switches.locked {
|
||||
return Err(bad("keys.set", "the layer is locked"));
|
||||
}
|
||||
let t = key_time(layer, comp.frame_rate, p, "time", base, "keys.set", false)?.ok_or_else(|| bad("keys.set", "missing `time` (layer time by default)"))?;
|
||||
let new_t = key_time(layer, comp.frame_rate, p, "newTime", base, "keys.set", true)?;
|
||||
let value = p.get("value").cloned();
|
||||
@@ -350,6 +370,9 @@ fn set_ease(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let influence = f_p(p, "influence").map(|v| (v / 100.0).clamp(0.001, 1.0));
|
||||
s.edit("Keyframe Velocity", merge_p(p), |proj, _| {
|
||||
let l = super::layer_mut(proj, cid, lid)?;
|
||||
if l.switches.locked {
|
||||
return Err(bad("keys.setEase", "the layer is locked"));
|
||||
}
|
||||
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.setEase", "no property"))?;
|
||||
let i = nearest_key(&pr.keys, t).ok_or_else(|| bad("keys.setEase", "no keyframes"))?;
|
||||
let spatial = pr.spatial;
|
||||
@@ -496,7 +519,13 @@ fn time_reverse(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
let mut new_sel = vec![];
|
||||
for ((lid, uid), times) in groups {
|
||||
let Some(pr) = comp.layer_mut(lid).and_then(|l| l.props.find_mut(uid)) else { continue };
|
||||
let Some(layer) = comp.layer_mut(lid) else { continue };
|
||||
// A locked layer's keys stay as they are, and stay selected.
|
||||
if layer.switches.locked {
|
||||
new_sel.extend(times.iter().map(|time| KeyRef { layer: lid, prop: uid, time: *time }));
|
||||
continue;
|
||||
}
|
||||
let Some(pr) = layer.props.find_mut(uid) else { continue };
|
||||
let (mut picked, rest): (Vec<Keyframe>, Vec<Keyframe>) = pr.keys.drain(..).partition(|k| times.contains(&k.time));
|
||||
effectcraft_keyframe::time_reverse(&mut picked);
|
||||
new_sel.extend(picked.iter().map(|k| KeyRef { layer: lid, prop: uid, time: k.time }));
|
||||
|
||||
@@ -213,7 +213,8 @@ fn sample_curve(
|
||||
|
||||
fn grouped_selection(s: &Session) -> std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> {
|
||||
let mut g: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
|
||||
for k in &s.state.selected_keys {
|
||||
let Some(comp) = s.active_comp() else { return g };
|
||||
for k in super::prop::editable_keys(comp, &s.state.selected_keys) {
|
||||
g.entry((k.layer, k.prop)).or_default().push(k.time);
|
||||
}
|
||||
for v in g.values_mut() {
|
||||
@@ -273,7 +274,7 @@ fn run_on_selection(
|
||||
s.edit(label, None, |proj, st| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let fr = comp.frame_rate;
|
||||
let mut sel = vec![];
|
||||
let mut sel = super::anim::locked_selection(comp, &st.selected_keys);
|
||||
for ((lid, uid), times) in groups {
|
||||
let Some(l) = comp.layer_mut(lid) else { continue };
|
||||
let lc = l.clone();
|
||||
|
||||
@@ -271,7 +271,8 @@ fn add_keyframe(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
/// Selected keys grouped by (layer, property).
|
||||
fn keys_by_prop(s: &Session) -> BTreeMap<(LayerId, Uid), Vec<Tick>> {
|
||||
let mut m: BTreeMap<(LayerId, Uid), Vec<Tick>> = BTreeMap::new();
|
||||
for k in &s.state.selected_keys {
|
||||
let Some(comp) = s.active_comp() else { return m };
|
||||
for k in super::prop::editable_keys(comp, &s.state.selected_keys) {
|
||||
m.entry((k.layer, k.prop)).or_default().push(k.time);
|
||||
}
|
||||
m
|
||||
|
||||
@@ -6,7 +6,7 @@ use effectcraft_project::{Comp, Expression, ItemId, ItemKind, Layer, LayerId, La
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, has_comp, has_layers, layers_p, match_path_of, selected_leaf_props, str_p};
|
||||
use super::{CommandSpec, has_comp, has_layers, layers_or, layers_p, match_path_of, selected_leaf_props, str_p};
|
||||
use crate::{EngineError, KeyRef, LinkClip, Result, Session, cmd};
|
||||
|
||||
fn can_undo(s: &Session) -> std::result::Result<(), String> {
|
||||
@@ -104,9 +104,16 @@ pub(crate) fn reid(layer: &mut Layer, next: &mut u64) {
|
||||
*next += 1;
|
||||
}
|
||||
|
||||
/// `edit.duplicate`: copies the targets in place. Without `layers` the targets are, in order:
|
||||
/// during a text edit the edited layer (the edit is committed first, as `text.endEdit` does),
|
||||
/// else the selected effects, shape items or text animators, else the selected layers. Layer
|
||||
/// copies are selected and their ids returned; the other paths return their delegated result.
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Effects selected → duplicate them (Edit ▸ Duplicate in Effect Controls).
|
||||
if p.get("layers").is_none() {
|
||||
let editing = s.state.text_edit.is_some();
|
||||
// Effects selected → duplicate them (Edit ▸ Duplicate in Effect Controls). An open text edit
|
||||
// comes first, as it does for Clear: opening the editor keeps the property selection, but
|
||||
// while typing, Duplicate means the edited layer, not an effect or item selected before.
|
||||
if p.get("layers").is_none() && !editing {
|
||||
let fx = super::effect::selected_effects(s);
|
||||
if !fx.is_empty() {
|
||||
let mut out = vec![];
|
||||
@@ -126,7 +133,17 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return super::prop_groups::duplicate_contents(s, &items);
|
||||
}
|
||||
}
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
// A deselected layer never keeps an open text edit (#2226): the copies take the selection, so
|
||||
// commit an open edit first, exactly as `text.endEdit` and Deselect All do — one call on the
|
||||
// command path so every route (menu, shortcut, palette, MCP, script) ends the edit and leaves
|
||||
// the same final state. Unnamed, the target is the edited layer even when an engine route
|
||||
// selected another one meanwhile.
|
||||
let edited = s.state.text_edit.as_ref().map(|e| e.layer);
|
||||
super::text_edit::end_edit(s, &Value::Null)?;
|
||||
let (cid, ids) = match edited {
|
||||
Some(layer) => layers_or(s, p, &[layer])?,
|
||||
None => layers_p(s, p)?,
|
||||
};
|
||||
let new = s.edit("Duplicate", None, |proj, st| {
|
||||
let mut next = proj.next_id;
|
||||
let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?;
|
||||
@@ -143,14 +160,16 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
relink_copies(comp, &map);
|
||||
proj.next_id = next;
|
||||
st.selected_layers = created.clone();
|
||||
// The property, keyframe and mask-vertex selection the edit kept belongs to the originals;
|
||||
// left in place, the next Duplicate or Clear would act on it, not on the copies. So during
|
||||
// an edit the copies take the selection the way a click on them would.
|
||||
if editing {
|
||||
super::layer::select_only(st, created.clone());
|
||||
} else {
|
||||
st.selected_layers = created.clone();
|
||||
}
|
||||
Ok(created)
|
||||
})?;
|
||||
// Duplicating selects the copies, so an open text edit on a now-deselected layer ends
|
||||
// (#2226): typing must not keep editing an unselected layer.
|
||||
if s.state.text_edit.as_ref().is_some_and(|e| !s.state.selected_layers.contains(&e.layer)) {
|
||||
super::text_edit::end_edit(s, &Value::Null)?;
|
||||
}
|
||||
Ok(json!(new.iter().map(|l| l.0).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
@@ -968,7 +987,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
paste_reversed
|
||||
),
|
||||
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", layers_or_keys, delete),
|
||||
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?} (effects or shape items when they are selected)", has_layers, duplicate),
|
||||
cmd!(
|
||||
"edit.duplicate",
|
||||
"Duplicate",
|
||||
["Edit"],
|
||||
Some("Cmd+D"),
|
||||
"{layers?} (without layers: during a text edit the edited layer, else the selected effects, shape items or text animators, else the selected layers)",
|
||||
layers_or_text_edit,
|
||||
duplicate
|
||||
),
|
||||
cmd!("edit.splitLayer", "Split Layer", ["Edit"], Some("Cmd+Shift+D"), "{layers?}", has_layers, split),
|
||||
cmd!("edit.liftWorkArea", "Lift Work Area", ["Edit"], None, "{layers?}", has_comp, lift),
|
||||
cmd!("edit.extractWorkArea", "Extract Work Area", ["Edit"], None, "{layers?}", has_comp, extract),
|
||||
@@ -1038,6 +1065,11 @@ fn layers_or_keys(s: &Session) -> std::result::Result<(), String> {
|
||||
if !s.state.selected_keys.is_empty() || !s.state.selected_vertices.is_empty() { super::has_comp(s) } else { has_layers(s) }
|
||||
}
|
||||
|
||||
/// Duplicate's unnamed target: the edited layer during a text edit, else the selection.
|
||||
fn layers_or_text_edit(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.state.text_edit.is_some() { super::has_comp(s) } else { has_layers(s) }
|
||||
}
|
||||
|
||||
fn always_ok(_: &Session) -> std::result::Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -72,6 +72,10 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut new_sel = vec![];
|
||||
for ((lid, uid), times) in groups {
|
||||
let Some(layer) = comp.layer_mut(lid) else { continue };
|
||||
if layer.switches.locked {
|
||||
new_sel.extend(times.into_iter().map(|time| KeyRef { layer: lid, prop: uid, time }));
|
||||
continue;
|
||||
}
|
||||
let (start, stretch) = (layer.start_time, layer.stretch);
|
||||
let to_comp = |lt: Tick| if (stretch - 100.0).abs() < 1e-9 { start + lt } else { start + Tick((lt.0 as f64 * stretch / 100.0) as i64) };
|
||||
let to_layer = |ct: Tick| {
|
||||
@@ -122,6 +126,9 @@ fn spatial_tangents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let brk = b_p(p, "break").unwrap_or(false);
|
||||
s.edit("Edit Spatial Tangents", merge_p(p), |proj, _| {
|
||||
let l = super::layer_mut(proj, cid, lid)?;
|
||||
if l.switches.locked {
|
||||
return Err(bad("keys.setSpatialTangents", "the layer is locked"));
|
||||
}
|
||||
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.setSpatialTangents", "no property"))?;
|
||||
if !pr.spatial {
|
||||
return Err(bad("keys.setSpatialTangents", "not a spatial property"));
|
||||
|
||||
@@ -37,6 +37,8 @@ pub(crate) fn label_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("keys.label", "select keyframes first"));
|
||||
}
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let keys = super::prop::editable_keys(comp, &keys);
|
||||
let n = s.edit("Keyframe Label", None, |proj, _| {
|
||||
let mut n = 0;
|
||||
for k in &keys {
|
||||
|
||||
@@ -28,6 +28,7 @@ pub(crate) use create::{color_p, index_p, insert_layer, insert_layer_at, place,
|
||||
pub(crate) use parent::parent_fixes;
|
||||
use parent::set_parent;
|
||||
use replace::{has_replace_source, replace_source};
|
||||
pub(crate) use select::select_only;
|
||||
use select::{select, select_next, select_prev, select_step};
|
||||
use switches::{blend, rename, set_comment, set_switch, track_matte};
|
||||
use timing::{arrange, move_frames, precompose, slip, timing};
|
||||
@@ -147,7 +148,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("layer.moveLater", "Move Layers 1 Frame Later", [], Some("Alt+PageDown"), "{layers?}", has_layers, |s, p| move_frames(s, p, 1)),
|
||||
cmd!("layer.moveEarlier10", "Move Layers 10 Frames Earlier", [], Some("Alt+Shift+PageUp"), "{layers?}", has_layers, |s, p| move_frames(s, p, -10)),
|
||||
cmd!("layer.moveLater10", "Move Layers 10 Frames Later", [], Some("Alt+Shift+PageDown"), "{layers?}", has_layers, |s, p| move_frames(s, p, 10)),
|
||||
cmd!("layer.slip", "Slip Edit", [], None, "{layers?, delta? (seconds) | frames?, merge?}", has_layers, slip),
|
||||
cmd!(
|
||||
"layer.slip",
|
||||
"Slip Edit",
|
||||
[],
|
||||
None,
|
||||
"{layers?, delta? (seconds) | frames?, merge?} → {requested, slipped: [{layer, delta, clamped}]}",
|
||||
has_layers,
|
||||
slip
|
||||
),
|
||||
cmd!("layer.slipBack", "Slip Edit 1 Frame Earlier", [], None, "{layers?}", has_layers, |s, p| {
|
||||
let mut p = p.clone();
|
||||
p["frames"] = json!(-1);
|
||||
|
||||
@@ -25,15 +25,21 @@ pub(super) fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
s.state.selected_layers = ids;
|
||||
s.state.selected_props.clear();
|
||||
s.state.selected_keys.clear();
|
||||
let keep = s.state.selected_layers.clone();
|
||||
s.state.selected_vertices.retain(|v| keep.contains(&v.layer));
|
||||
select_only(&mut s.state, ids);
|
||||
}
|
||||
Ok(json!(s.state.selected_layers.iter().map(|l| l.0).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
/// Make `ids` the whole layer selection, as a plain click on them does: no property or keyframe
|
||||
/// stays selected, and mask vertices stay selected only on layers that are still selected.
|
||||
pub(crate) fn select_only(st: &mut crate::EditorState, ids: Vec<LayerId>) {
|
||||
st.selected_layers = ids;
|
||||
st.selected_props.clear();
|
||||
st.selected_keys.clear();
|
||||
let keep = st.selected_layers.clone();
|
||||
st.selected_vertices.retain(|v| keep.contains(&v.layer));
|
||||
}
|
||||
|
||||
pub(super) fn select_step(s: &mut Session, p: &Value, dir: i64) -> Result<Value> {
|
||||
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
|
||||
if comp.layers.is_empty() {
|
||||
|
||||
@@ -84,8 +84,9 @@ pub(super) fn move_frames(s: &mut Session, p: &Value, frames: i64) -> Result<Val
|
||||
|
||||
/// Slip edit: move a layer's source (footage or precomp) under its fixed in and out points, like
|
||||
/// dragging the source-duration bar in After Effects. `delta` in seconds or `frames`; positive
|
||||
/// moves the source later. Clamped so the source still covers the in–out span. Keyframes and
|
||||
/// layer markers are in layer time, so they travel with the source.
|
||||
/// moves the source later. Clamped so the source still covers the in–out span: the result gives
|
||||
/// the `requested` shift and, per layer, the `delta` applied and whether it was `clamped` (#2371).
|
||||
/// Keyframes and layer markers are in layer time, so they travel with the source.
|
||||
pub(super) fn slip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
@@ -130,7 +131,10 @@ pub(super) fn slip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"slipped": shifts.iter().map(|(id, d)| json!({"layer": id.0, "delta": d.seconds()})).collect::<Vec<_>>()}))
|
||||
Ok(json!({
|
||||
"requested": delta.seconds(),
|
||||
"slipped": shifts.iter().map(|(id, d)| json!({"layer": id.0, "delta": d.seconds(), "clamped": *d != delta})).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -438,20 +438,22 @@ fn interpolate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// The layer: given, else the layer of selected Mask Path keys, else the selection.
|
||||
let cid = super::comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let selected = super::prop::editable_keys(comp, &s.state.selected_keys);
|
||||
let lid = match p.get("layer") {
|
||||
Some(v) => super::resolve_layer(comp, v).ok_or_else(|| bad(CMD, format!("no layer {v}")))?,
|
||||
None => match s.state.selected_keys.iter().find(|k| comp.layer(k.layer).is_some_and(|l| l.masks().is_some_and(|m| m.find(k.prop).is_some()))) {
|
||||
None => match selected.iter().find(|k| comp.layer(k.layer).is_some_and(|l| l.masks().is_some_and(|m| m.find(k.prop).is_some()))) {
|
||||
Some(k) => k.layer,
|
||||
None => layer_p(s, p, CMD)?.1,
|
||||
},
|
||||
};
|
||||
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?;
|
||||
if !super::prop::key_layer_editable(comp, lid) {
|
||||
return Err(bad(CMD, "the layer is locked"));
|
||||
}
|
||||
let masks = layer.masks().ok_or_else(|| bad(CMD, "the layer has no masks"))?;
|
||||
let uid = match p.get("mask") {
|
||||
Some(_) => mask_uid(s, layer, p),
|
||||
None => s
|
||||
.state
|
||||
.selected_keys
|
||||
None => selected
|
||||
.iter()
|
||||
.filter(|k| k.layer == lid)
|
||||
.find_map(|k| masks.groups().find(|g| g.get("path").is_some_and(|pr| pr.uid == k.prop)).map(|g| g.uid))
|
||||
@@ -466,7 +468,7 @@ fn interpolate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ts: Vec<Tick> = a.iter().filter_map(Value::as_f64).map(|t| layer.layer_time(comp.frame_rate.snap_nearest(Tick::from_seconds_f64(t)))).collect();
|
||||
ts.into_iter().filter_map(|t| path_prop.keys.iter().find(|k| (k.time.0 - t.0).abs() <= comp.frame_duration().0 / 2).map(|k| k.time)).collect()
|
||||
}
|
||||
None => s.state.selected_keys.iter().filter(|k| k.layer == lid && k.prop == path_prop.uid).map(|k| k.time).collect(),
|
||||
None => selected.iter().filter(|k| k.layer == lid && k.prop == path_prop.uid).map(|k| k.time).collect(),
|
||||
};
|
||||
times.sort();
|
||||
times.dedup();
|
||||
|
||||
@@ -148,6 +148,11 @@ pub(crate) fn layer_p(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, Laye
|
||||
|
||||
/// `layers` param (array) or the selection.
|
||||
pub(crate) fn layers_p(s: &Session, p: &Value) -> Result<(ItemId, Vec<LayerId>)> {
|
||||
layers_or(s, p, &s.state.selected_layers)
|
||||
}
|
||||
|
||||
/// `layers` param (array), else `layer`, else those of `fallback` still in the comp.
|
||||
pub(crate) fn layers_or(s: &Session, p: &Value, fallback: &[LayerId]) -> Result<(ItemId, Vec<LayerId>)> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
// Explicit references that match no layer are an error (agents otherwise get a silent no-op).
|
||||
@@ -159,7 +164,7 @@ pub(crate) fn layers_p(s: &Session, p: &Value) -> Result<(ItemId, Vec<LayerId>)>
|
||||
if let Some(v) = p.get("layer").filter(|v| !v.is_null()) {
|
||||
return Ok((cid, vec![resolve(v)?]));
|
||||
}
|
||||
Ok((cid, s.state.selected_layers.iter().copied().filter(|l| comp.layer(*l).is_some()).collect()))
|
||||
Ok((cid, fallback.iter().copied().filter(|l| comp.layer(*l).is_some()).collect()))
|
||||
}
|
||||
|
||||
/// Whether a layer can be picked by the user (Select All, Ctrl+Up/Down, a Timeline click): not
|
||||
|
||||
@@ -247,7 +247,8 @@ fn property_reply(p: &Value, layer: &Layer, pr: &Property, time: Tick, value: Va
|
||||
/// property (by match path, `transform/position`, `effects/<effect>#n/<param>`) of every other
|
||||
/// selected, unlocked layer of the comp whose value is of the same kind, as (layer, uid). Empty
|
||||
/// when `selected` is not set or the edited layer isn't selected. After Effects applies a value
|
||||
/// edit, the stopwatch and the keyframe button to all selected layers (#355, #491).
|
||||
/// edit, the stopwatch and the keyframe button to all selected layers (#355, #491). A parameter
|
||||
/// of a selected Puppet pin also reaches the same parameter of the other selected pins (#1751).
|
||||
fn selected_twins(s: &Session, p: &Value, cmd: &str) -> Result<Vec<(LayerId, Uid)>> {
|
||||
if b_p(p, "selected") != Some(true) {
|
||||
return Ok(vec![]);
|
||||
@@ -260,14 +261,31 @@ fn selected_twins(s: &Session, p: &Value, cmd: &str) -> Result<Vec<(LayerId, Uid
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let kind = (pr.value.kind_name(), pr.value.components().len());
|
||||
Ok(s.state
|
||||
let same_kind = |q: &&Property| (q.value.kind_name(), q.value.components().len()) == kind;
|
||||
let mut twins: Vec<(LayerId, Uid)> = s
|
||||
.state
|
||||
.selected_layers
|
||||
.iter()
|
||||
.filter(|o| **o != lid)
|
||||
.filter_map(|o| comp.layer(*o))
|
||||
.filter(|l| !l.switches.locked)
|
||||
.filter_map(|l| l.props.prop(&path).filter(|q| (q.value.kind_name(), q.value.components().len()) == kind).map(|q| (l.id, q.uid)))
|
||||
.collect())
|
||||
.filter_map(|l| l.props.prop(&path).filter(same_kind).map(|q| (l.id, q.uid)))
|
||||
.collect();
|
||||
// A parameter of a selected Puppet pin: the same parameter of every other selected pin, on
|
||||
// this layer too, so several pins move or key together (#1751).
|
||||
let pin = super::puppet::layer_pins(layer).find(|g| g.props().any(|q| q.uid == uid)).map(|g| g.uid);
|
||||
if let Some(pin) = pin
|
||||
&& s.state.selected_props.contains(&(lid, pin))
|
||||
{
|
||||
for (ol, ou) in &s.state.selected_props {
|
||||
let Some(other) = comp.layer(*ol).filter(|l| !l.switches.locked) else { continue };
|
||||
let Some(q) = super::puppet::layer_pins(other).find(|g| g.uid == *ou).and_then(|g| g.get(&pr.match_id)).filter(same_kind) else { continue };
|
||||
if (*ol, q.uid) != (lid, uid) && !twins.contains(&(*ol, q.uid)) {
|
||||
twins.push((*ol, q.uid));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(twins)
|
||||
}
|
||||
|
||||
/// `p` aimed at property `uid` of layer `lid` instead.
|
||||
@@ -286,8 +304,13 @@ fn twin_params(p: &Value, lid: LayerId, uid: Uid) -> Value {
|
||||
/// The value of property `uid` of `lid` at the CTI as numbers (Position with Separate
|
||||
/// Dimensions: its X / Y / Z Position).
|
||||
fn current_components(s: &Session, cid: ItemId, lid: LayerId, uid: Uid) -> Option<Vec<f64>> {
|
||||
components_at(s, cid, lid, uid, None)
|
||||
}
|
||||
|
||||
/// [`current_components`] at layer time `at` (the CTI when `None`).
|
||||
fn components_at(s: &Session, cid: ItemId, lid: LayerId, uid: Uid, at: Option<Tick>) -> Option<Vec<f64>> {
|
||||
let l = s.project.comp(cid)?.layer(lid)?;
|
||||
let lt = l.layer_time(s.time_of(cid));
|
||||
let lt = at.unwrap_or_else(|| l.layer_time(s.time_of(cid)));
|
||||
if let Some(tr) = l.transform()
|
||||
&& tr.get("position").is_some_and(|q| q.uid == uid)
|
||||
&& tr.get("positionX").is_some()
|
||||
@@ -319,7 +342,9 @@ pub(crate) fn proportional_share(index: usize, count: usize) -> f64 {
|
||||
/// `prop.set`; with `selected`, also on the other selected layers: the same value, or with
|
||||
/// `offset` (a scrub) the same change from each layer's own value. A typed value with `dims`
|
||||
/// (the components edited, e.g. `[2]` for Z) sets only those components on the other layers,
|
||||
/// which keep the rest of their own value (#1434). With `offset` and
|
||||
/// which keep the rest of their own value (#1434); `follow` components move on each layer by the
|
||||
/// change its first typed component takes (linked Audio Levels keep each layer's balance, #2485).
|
||||
/// With `offset` and
|
||||
/// `proportional` (Proportional Scrubbing) each layer takes [`proportional_share`] of the change
|
||||
/// by its place in the selection order, the scrubbed layer too. One undo step (with `merge`,
|
||||
/// one that a continuing scrub keeps folding into).
|
||||
@@ -344,10 +369,19 @@ fn set_selected(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(None, Some(a), Some(_)) if !scalar => Some(a.iter().filter_map(Value::as_u64).filter_map(|d| usize::try_from(d).ok()).collect()),
|
||||
_ => None,
|
||||
};
|
||||
let follow: Vec<usize> =
|
||||
p.get("follow").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).filter_map(|d| usize::try_from(d).ok()).collect();
|
||||
// Each layer's own value where `set` writes it (a `time` other than the CTI keys there).
|
||||
let typed = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid| {
|
||||
if let (Some(dims), Some(n)) = (&dims, &new)
|
||||
&& let Some(mut c) = current_components(s, cid, olid, ouid)
|
||||
&& let Some(mut c) = components_at(s, cid, olid, ouid, snapped_key_time(s, cid, olid, q, "prop.set").ok().flatten())
|
||||
{
|
||||
let change = dims.first().and_then(|&d| Some(n.get(d)? - c.get(d)?)).unwrap_or(0.0);
|
||||
for &d in &follow {
|
||||
if let Some(slot) = c.get_mut(d) {
|
||||
*slot += change;
|
||||
}
|
||||
}
|
||||
for &d in dims {
|
||||
if let (Some(slot), Some(v)) = (c.get_mut(d), n.get(d)) {
|
||||
*slot = *v;
|
||||
@@ -914,6 +948,15 @@ fn select_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// References that can be edited, including explicit key lists supplied by commands.
|
||||
pub(crate) fn editable_keys(comp: &effectcraft_project::Comp, selected: &[KeyRef]) -> Vec<KeyRef> {
|
||||
selected.iter().filter(|key| key_layer_editable(comp, key.layer)).copied().collect()
|
||||
}
|
||||
|
||||
pub(crate) fn key_layer_editable(comp: &effectcraft_project::Comp, layer: LayerId) -> bool {
|
||||
comp.layer(layer).is_some_and(|layer| !layer.switches.locked)
|
||||
}
|
||||
|
||||
/// Apply `f` to every selected key; keeps the selection pointing at moved keys.
|
||||
pub(crate) fn edit_keys(s: &mut Session, label: &str, merge: Option<&str>, f: impl Fn(&mut Vec<Keyframe>, usize, &mut Tick) -> bool) -> Result<Value> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
@@ -926,7 +969,16 @@ pub(crate) fn edit_keys(s: &mut Session, label: &str, merge: Option<&str>, f: im
|
||||
groups.entry((k.layer, k.prop)).or_default().push(k.time);
|
||||
}
|
||||
for ((lid, uid), times) in groups {
|
||||
let Some(pr) = comp.layer_mut(lid).and_then(|l| l.props.find_mut(uid)) else { continue };
|
||||
let Some(layer) = comp.layer_mut(lid) else { continue };
|
||||
// A locked layer's keys stay as they are, and stay selected.
|
||||
if layer.switches.locked {
|
||||
for time in times {
|
||||
let r = KeyRef { layer: lid, prop: uid, time };
|
||||
remapped.insert(r, r);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(pr) = layer.props.find_mut(uid) else { continue };
|
||||
// Process each stored key once, in an order that avoids collisions when moving.
|
||||
let mut idx: Vec<(usize, Tick)> = times.iter().filter_map(|t| key_at(&pr.keys, *t).map(|i| (i, *t))).collect();
|
||||
idx.sort_by_key(|x| x.0);
|
||||
@@ -1079,6 +1131,10 @@ fn delete_keys(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let mut removed = 0usize;
|
||||
let mut changed = std::collections::BTreeSet::new();
|
||||
for k in &sel {
|
||||
// A layer locked after its keys were selected keeps them (the selection stays too).
|
||||
if comp.layer(k.layer).is_some_and(|l| l.switches.locked) {
|
||||
continue;
|
||||
}
|
||||
if let Some(pr) = comp.layer_mut(k.layer).and_then(|l| l.props.find_mut(k.prop))
|
||||
&& let Some(i) = key_at(&pr.keys, k.time)
|
||||
{
|
||||
@@ -1092,7 +1148,7 @@ fn delete_keys(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
st.selected_keys.clear();
|
||||
st.selected_keys.retain(|k| comp.layer(k.layer).is_some_and(|l| l.switches.locked));
|
||||
for (layer, prop) in changed {
|
||||
sync_polystar_property(proj, st, cid, layer, prop, time)?;
|
||||
}
|
||||
@@ -1366,7 +1422,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Property Value",
|
||||
[],
|
||||
None,
|
||||
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all), dims? (with selected, a typed value: the components edited, e.g. [2] for Z; the other layers keep the rest of their own value)}",
|
||||
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all), dims? (with selected, a typed value: the components edited, e.g. [2] for Z; the other layers keep the rest of their own value), follow? (with dims: components that move on each other layer by the change its first typed component takes)}",
|
||||
has_layers,
|
||||
set_selected
|
||||
),
|
||||
|
||||
@@ -5,14 +5,16 @@
|
||||
//! The native `.prproj` format has no public specification and is not read or written.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_interchange::{ExportOptions, ImportOptions, PrecompMode, PrerenderMode, Prerendered, TimelineFormat};
|
||||
use effectcraft_project::render_queue::{AudioOutput, Channels, OutputFormat, OutputModule, ProResProfile, RenderQueueItem, TimeSpan};
|
||||
use effectcraft_project::{ItemId, LayerId, LayerSource, Project};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, comp_id, f_p, has_comp, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, str_p};
|
||||
use crate::jobs::TaskCtl;
|
||||
use crate::{EngineError, Exporter, Result, Session, cmd};
|
||||
|
||||
fn format_p(p: &Value, cmd: &str) -> Result<Option<TimelineFormat>> {
|
||||
match str_p(p, "format") {
|
||||
@@ -95,18 +97,34 @@ fn isolate(project: &Project, cid: ItemId, lid: LayerId) -> Option<Project> {
|
||||
Some(q)
|
||||
}
|
||||
|
||||
/// Render one layer to ProRes 4444 with alpha over its in–out span.
|
||||
fn prerender(s: &Session, cid: ItemId, lid: LayerId, path: &str) -> std::result::Result<Prerendered, String> {
|
||||
let exporter = s.exporter.clone().ok_or("export is not available in this build")?;
|
||||
/// What a pre-render needs from the session, so it can run on a background thread.
|
||||
struct PrerenderCx {
|
||||
exporter: Option<Arc<dyn Exporter>>,
|
||||
footage: Arc<dyn effectcraft_render::FootageSource>,
|
||||
expr: Option<Arc<dyn effectcraft_render::ExprHost>>,
|
||||
accel: Option<Arc<dyn effectcraft_render::Accelerator>>,
|
||||
nested_switches: bool,
|
||||
}
|
||||
|
||||
/// Render one layer to ProRes 4444 with alpha over its in–out span; `progress` as the exporter's.
|
||||
fn prerender(
|
||||
cx: &PrerenderCx,
|
||||
project: &Project,
|
||||
cid: ItemId,
|
||||
lid: LayerId,
|
||||
path: &str,
|
||||
progress: &mut dyn FnMut(u64, u64) -> bool,
|
||||
) -> std::result::Result<Prerendered, String> {
|
||||
let exporter = cx.exporter.as_ref().ok_or("export is not available in this build")?;
|
||||
if !exporter.formats().contains(&OutputFormat::ProRes) {
|
||||
return Err("ProRes export is not available in this build".into());
|
||||
}
|
||||
let comp = s.project.comp(cid).ok_or("no composition")?;
|
||||
let comp = project.comp(cid).ok_or("no composition")?;
|
||||
let l = comp.layer(lid).ok_or("no layer")?;
|
||||
let fd = comp.frame_duration();
|
||||
let start = comp.frame_rate.snap(l.in_point.max(effectcraft_time::Tick::ZERO));
|
||||
let end = l.out_point.min(comp.duration).max(start + fd);
|
||||
let q = isolate(&s.project, cid, lid).ok_or("no composition")?;
|
||||
let q = isolate(project, cid, lid).ok_or("no composition")?;
|
||||
let mut item = RenderQueueItem::new(0, cid);
|
||||
item.settings.time_span = TimeSpan::Custom { start, end };
|
||||
item.output = OutputModule::for_format(OutputFormat::ProRes);
|
||||
@@ -116,16 +134,16 @@ fn prerender(s: &Session, cid: ItemId, lid: LayerId, path: &str) -> std::result:
|
||||
item.output.output = path.to_string();
|
||||
let job = crate::ExportJob {
|
||||
project: &q,
|
||||
footage: s.footage.as_ref(),
|
||||
expr: s.expr.as_deref(),
|
||||
accel: s.accel.as_deref(),
|
||||
footage: cx.footage.as_ref(),
|
||||
expr: cx.expr.as_deref(),
|
||||
accel: cx.accel.as_deref(),
|
||||
item: &item,
|
||||
path,
|
||||
storage: None,
|
||||
label: format!("Pre-render {}", l.name),
|
||||
nested_switches: s.prefs.general.switches_affect_nested_comps,
|
||||
nested_switches: cx.nested_switches,
|
||||
};
|
||||
let r = exporter.export(&job, &mut |_, _| true)?;
|
||||
let r = exporter.export(&job, progress)?;
|
||||
Ok(Prerendered { path: r.path, width: comp.width, height: comp.height, frame_rate: comp.frame_rate, duration: end - start })
|
||||
}
|
||||
|
||||
@@ -157,33 +175,67 @@ fn export_timeline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let stem = fp.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Timeline".into());
|
||||
let relative_to = Some(dir.to_string_lossy().to_string()).filter(|d| !d.is_empty());
|
||||
let opts = ExportOptions { format, prerender: prerender_mode, precomps, relative_to };
|
||||
// Pre-render what the timeline can't represent.
|
||||
let project = s.project.clone();
|
||||
let services = s.services.clone();
|
||||
let cx = PrerenderCx {
|
||||
exporter: s.exporter.clone(),
|
||||
footage: s.footage.clone(),
|
||||
expr: s.expr.clone(),
|
||||
accel: s.accel.clone(),
|
||||
nested_switches: s.prefs.general.switches_affect_nested_comps,
|
||||
};
|
||||
// The pre-renders can take minutes: with `background` (the export dialog) they run as a job
|
||||
// in Window ▸ Progress instead of with the window waiting (#2269).
|
||||
let wait = !b_p(p, "background").unwrap_or(false);
|
||||
s.spawn_task("exportTimeline", format!("Export {}", format.label()), wait, move |ctl| {
|
||||
let (bytes, mut result) = export_timeline_work(&cx, &project, cid, &opts, &dir, &stem, ctl)?;
|
||||
services.write_file(&path, &bytes).map_err(|e| format!("cannot write {path}: {e}"))?;
|
||||
result["path"] = json!(path);
|
||||
result["toast"] = json!(match format {
|
||||
TimelineFormat::Fcp7Xml => format!("Exported {path} (Final Cut Pro XML; import it in Premiere Pro with File ▸ Import)"),
|
||||
_ => format!("Exported {path} ({})", format.label()),
|
||||
});
|
||||
Ok(Box::new(move |_: &mut Session| Ok(result)))
|
||||
})
|
||||
}
|
||||
|
||||
/// Pre-render what the timeline can't represent, then build the timeline: its bytes and the
|
||||
/// command's result (without `path`).
|
||||
fn export_timeline_work(
|
||||
cx: &PrerenderCx,
|
||||
project: &Project,
|
||||
cid: ItemId,
|
||||
opts: &ExportOptions,
|
||||
dir: &std::path::Path,
|
||||
stem: &str,
|
||||
ctl: &TaskCtl,
|
||||
) -> std::result::Result<(Vec<u8>, Value), String> {
|
||||
let mut pre: HashMap<(ItemId, LayerId), Prerendered> = HashMap::new();
|
||||
let mut rendered = vec![];
|
||||
let mut errors = vec![];
|
||||
for (c, l) in effectcraft_interchange::plan_prerender(&s.project, cid, &opts) {
|
||||
let comp_name = s.project.item(c).map(|i| i.name.clone()).unwrap_or_default();
|
||||
let layer_name = s.project.comp(c).and_then(|cc| cc.layer(l)).map(|x| x.name.clone()).unwrap_or_default();
|
||||
let plan = effectcraft_interchange::plan_prerender(project, cid, opts);
|
||||
let n = plan.len() as u64;
|
||||
for (i, (c, l)) in (0u64..).zip(plan) {
|
||||
let comp_name = project.item(c).map(|it| it.name.clone()).unwrap_or_default();
|
||||
let layer_name = project.comp(c).and_then(|cc| cc.layer(l)).map(|x| x.name.clone()).unwrap_or_default();
|
||||
ctl.message(format!("Pre-render {layer_name}"));
|
||||
let file = dir.join(format!("{stem}_{}_{}_{}.mov", safe(&comp_name), safe(&layer_name), l.0)).to_string_lossy().to_string();
|
||||
match prerender(s, c, l, &file) {
|
||||
// Progress over all the layers, each one's frames a share of it.
|
||||
let mut progress = |done: u64, total: u64| ctl.progress(i * 1000 + done.saturating_mul(1000) / total.max(1), n * 1000);
|
||||
match prerender(cx, project, c, l, &file, &mut progress) {
|
||||
Ok(r) => {
|
||||
rendered.push(r.path.clone());
|
||||
pre.insert((c, l), r);
|
||||
}
|
||||
Err(_) if ctl.cancelled() => return Err(crate::render_queue::CANCELLED.into()),
|
||||
Err(e) => errors.push(format!("{comp_name} ▸ {layer_name}: {e}")),
|
||||
}
|
||||
}
|
||||
let out = effectcraft_interchange::export(&s.project, cid, &opts, &pre).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
s.services.write_file(&path, &out.bytes).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;
|
||||
let out = effectcraft_interchange::export(project, cid, opts, &pre).map_err(|e| e.to_string())?;
|
||||
let mut warnings = out.warnings.clone();
|
||||
warnings.extend(errors.iter().map(|e| format!("pre-render failed: {e}")));
|
||||
s.toast(match format {
|
||||
TimelineFormat::Fcp7Xml => format!("Exported {path} (Final Cut Pro XML; import it in Premiere Pro with File ▸ Import)"),
|
||||
_ => format!("Exported {path} ({})", format.label()),
|
||||
});
|
||||
Ok(json!({
|
||||
"path": path,
|
||||
"format": format.id(),
|
||||
let result = json!({
|
||||
"format": opts.format.id(),
|
||||
"bytes": out.bytes.len(),
|
||||
"sequences": out.sequences,
|
||||
"videoTracks": out.video_tracks,
|
||||
@@ -191,7 +243,8 @@ fn export_timeline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"clips": out.clips,
|
||||
"prerendered": rendered,
|
||||
"warnings": warnings,
|
||||
}))
|
||||
});
|
||||
Ok((out.bytes, result))
|
||||
}
|
||||
|
||||
/// The formats, for agents and the export dialog.
|
||||
@@ -220,7 +273,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Adobe Premiere Pro Project...",
|
||||
["File", "Export"],
|
||||
None,
|
||||
"{comp?, path (Final Cut Pro XML .xml for Premiere Pro or .fcpxml .otio .edl .aaf .omf), format?: xml|fcpxml|otio|edl|aaf|omf, prerender?: none|unsupported|all, precomps?: nest|prerender}",
|
||||
"{comp?, path (Final Cut Pro XML .xml for Premiere Pro or .fcpxml .otio .edl .aaf .omf), format?: xml|fcpxml|otio|edl|aaf|omf, prerender?: none|unsupported|all, precomps?: nest|prerender, background?: bool (pre-render and write as a job, jobs.list / jobs.wait; returns {job})}",
|
||||
has_comp,
|
||||
export_timeline
|
||||
),
|
||||
|
||||
@@ -36,7 +36,7 @@ pub use sections::{
|
||||
use sections::{Extra, default_labels, lenient_comp_presets};
|
||||
|
||||
/// Current settings layout version.
|
||||
pub const PREFS_VERSION: u32 = 3;
|
||||
pub const PREFS_VERSION: u32 = 4;
|
||||
/// Settings file name in the config store.
|
||||
pub const PREFS_FILE: &str = "prefs.json";
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ use super::{Appearance, General, Map, PREFS_VERSION, Prefs, Startup, Value, json
|
||||
/// `autoSaveMinutes`, `autoSaveVersions`, `labelNames` (array) and `theme` at the top level.
|
||||
/// - version 2 defaulted the disk cache to 100 GB, more than most laptops have free; the stored
|
||||
/// old default becomes the new one (a size the user chose is kept).
|
||||
/// - version 3 and earlier could still hold `createSplitLayersAbove: false`, the default before
|
||||
/// #2083 (every save kept it); it becomes After Effects' default, above (#2545).
|
||||
pub fn migrate(mut v: Value) -> Value {
|
||||
let Some(obj) = v.as_object_mut() else { return json!({}) };
|
||||
let version = obj.get("version").and_then(Value::as_u64).unwrap_or(0);
|
||||
@@ -42,6 +44,12 @@ pub fn migrate(mut v: Value) -> Value {
|
||||
{
|
||||
d.insert("diskCacheMaxGb".into(), json!(20));
|
||||
}
|
||||
if version < 4
|
||||
&& let Some(g) = obj.get_mut("general").and_then(Value::as_object_mut)
|
||||
&& g.get("createSplitLayersAbove") == Some(&json!(false))
|
||||
{
|
||||
g.insert("createSplitLayersAbove".into(), json!(true));
|
||||
}
|
||||
// Settings saved before appearance modes had one theme: keep showing it, as a fixed Dark or
|
||||
// Light mode, instead of switching established users to Auto.
|
||||
if let Some(a) = obj.get_mut("appearance").and_then(Value::as_object_mut)
|
||||
|
||||
@@ -9,6 +9,80 @@ fn select(s: &mut crate::Session, layer: u64, path: &str) {
|
||||
s.execute("prop.select", json!({"layer": layer, "path": path})).unwrap();
|
||||
}
|
||||
|
||||
fn selection_command_skips_locked_keys(command: &str, path: &str, values: [serde_json::Value; 3], mut params: serde_json::Value) {
|
||||
let (mut s, locked) = setup();
|
||||
let unlocked = s.execute("layer.newSolid", json!({"name": "Editable", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
|
||||
for layer in [locked, unlocked] {
|
||||
animate(&mut s, layer, path, &[(0.0, values[0].clone()), (0.5, values[1].clone()), (1.0, values[2].clone())]);
|
||||
}
|
||||
let before = prop(&s, locked, path).keys;
|
||||
let selected: Vec<_> = [locked, unlocked]
|
||||
.into_iter()
|
||||
.flat_map(|layer| {
|
||||
let pr = prop(&s, layer, path);
|
||||
pr.keys.into_iter().map(move |key| crate::KeyRef { layer: effectcraft_project::LayerId(layer), prop: pr.uid, time: key.time })
|
||||
})
|
||||
.collect();
|
||||
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": true})).unwrap();
|
||||
s.state.selected_keys = selected.clone();
|
||||
if params.get("keys").is_some() {
|
||||
params["keys"] = serde_json::to_value(&selected).unwrap();
|
||||
}
|
||||
s.execute_checked(command, params.clone()).unwrap();
|
||||
assert_eq!(prop(&s, locked, path).keys, before, "{command} changed a locked layer");
|
||||
let after = prop(&s, unlocked, path).keys;
|
||||
assert_ne!(after, before, "{command} must still edit unlocked keys");
|
||||
let after_selection = s.state.selected_keys.clone();
|
||||
assert!(selected.iter().filter(|k| k.layer.0 == locked).all(|key| after_selection.contains(key)));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(prop(&s, unlocked, path).keys, before);
|
||||
assert!(selected.iter().filter(|key| key.layer.0 == locked).all(|key| s.state.selected_keys.contains(key)));
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(prop(&s, unlocked, path).keys, after);
|
||||
assert_eq!(s.state.selected_keys, after_selection);
|
||||
// All-locked input must preserve the selection and document as well.
|
||||
s.state.selected_keys.retain(|k| k.layer.0 == locked);
|
||||
if params.get("keys").is_some() {
|
||||
params["keys"] = serde_json::to_value(&s.state.selected_keys).unwrap();
|
||||
}
|
||||
let locked_selection = s.state.selected_keys.clone();
|
||||
let project = serde_json::to_value(&*s.project).unwrap();
|
||||
let _ = s.execute_checked(command, params);
|
||||
assert_eq!(serde_json::to_value(&*s.project).unwrap(), project);
|
||||
assert_eq!(s.state.selected_keys, locked_selection);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiggler_preserves_locked_keys_in_mixed_selection() {
|
||||
selection_command_skips_locked_keys(
|
||||
"keys.wiggle",
|
||||
"transform/opacity",
|
||||
[json!(10), json!(90), json!(20)],
|
||||
json!({"apply": "temporal", "frequency": 8, "magnitude": 10}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoother_preserves_locked_keys_in_mixed_selection() {
|
||||
selection_command_skips_locked_keys("keys.smooth", "transform/opacity", [json!(10), json!(50), json!(90)], json!({"tolerance": 1}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exponential_scale_preserves_locked_keys_in_mixed_selection() {
|
||||
selection_command_skips_locked_keys(
|
||||
"keys.exponentialScale",
|
||||
"transform/scale",
|
||||
[json!([100, 100, 100]), json!([150, 150, 150]), json!([400, 400, 400])],
|
||||
json!({}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_label_preserves_locked_keys_in_mixed_selection() {
|
||||
selection_command_skips_locked_keys("edit.label", "transform/opacity", [json!(10), json!(50), json!(90)], json!({"label": "Red"}));
|
||||
selection_command_skips_locked_keys("edit.label", "transform/opacity", [json!(10), json!(50), json!(90)], json!({"label": "Red", "keys": true}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiggle_adds_keys_at_frequency_within_magnitude() {
|
||||
let (mut s, l) = setup();
|
||||
|
||||
@@ -43,3 +43,207 @@ fn delete_keys_reports_zero_when_no_selected_key_exists() {
|
||||
assert_eq!(removed, json!(0));
|
||||
assert_eq!(keys_of(&s, layer, prop), keys);
|
||||
}
|
||||
|
||||
/// A layer locked after its keys were selected keeps them: Delete and Edit Value leave them alone.
|
||||
#[test]
|
||||
fn a_locked_layers_keys_cannot_be_deleted_or_edited() {
|
||||
let (mut s, layer, prop) = setup();
|
||||
let keys = keys_of(&s, layer, prop);
|
||||
s.state.selected_keys = keys.clone();
|
||||
s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap();
|
||||
let removed = s.execute_checked("keys.delete", json!({})).unwrap();
|
||||
assert_eq!(removed, json!(0));
|
||||
assert_eq!(keys_of(&s, layer, prop), keys);
|
||||
assert_eq!(s.state.selected_keys, keys, "the selection stays");
|
||||
let e = s.execute("keys.set", json!({"layer": layer, "prop": prop, "time": 0, "value": 90})).unwrap_err().to_string();
|
||||
assert!(e.contains("locked"), "{e}");
|
||||
let value = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys[0].value.as_f64();
|
||||
assert_eq!(value, 0.0);
|
||||
// Unlocked again, both work.
|
||||
s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap();
|
||||
s.execute_checked("keys.set", json!({"layer": layer, "prop": prop, "time": 0, "value": 90})).unwrap();
|
||||
assert_eq!(s.execute_checked("keys.delete", json!({})).unwrap(), json!(3));
|
||||
}
|
||||
|
||||
/// Easy Ease, Toggle Hold and Time-Reverse leave a locked layer's keys as they are and keep them
|
||||
/// selected; unlocked, they change them.
|
||||
#[test]
|
||||
fn selection_based_key_edits_skip_a_locked_layer() {
|
||||
let (mut s, layer, prop) = setup();
|
||||
let keys = keys_of(&s, layer, prop);
|
||||
let stored = |s: &Session| s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys.clone();
|
||||
let before = stored(&s);
|
||||
s.state.selected_keys = keys.clone();
|
||||
s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap();
|
||||
for command in ["keys.easyEase", "keys.toggleHold", "keys.timeReverse"] {
|
||||
s.execute_checked(command, json!({})).unwrap();
|
||||
assert_eq!(stored(&s), before, "{command} changed a locked layer's keys");
|
||||
assert_eq!(s.state.selected_keys, keys, "{command} dropped the selection of a locked layer's keys");
|
||||
}
|
||||
s.execute_checked("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap();
|
||||
s.execute_checked("keys.easyEase", json!({})).unwrap();
|
||||
assert_ne!(stored(&s), before, "unlocked, Easy Ease changes the keys");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spatial_handles_cannot_edit_a_locked_layer() {
|
||||
let (mut s, layer, _) = setup();
|
||||
for (time, value) in [(0, [10, 20, 0]), (1, [80, 60, 0])] {
|
||||
s.execute("prop.addKey", json!({"layer": layer, "path": "transform/position", "time": time, "value": value})).unwrap();
|
||||
}
|
||||
let prop = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("transform/position").unwrap().uid;
|
||||
s.execute("keys.select", json!({"keys": [{"layer": layer, "prop": prop, "time": 0}]})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap();
|
||||
let before = s.project.to_json();
|
||||
let selected = s.state.selected_keys.clone();
|
||||
let undo_len = s.history.undo.len();
|
||||
let params = json!({"layer": layer, "prop": prop, "time": 0, "out": [15, 25, 0]});
|
||||
let error = s.execute("keys.setSpatialTangents", params.clone()).unwrap_err().to_string();
|
||||
assert!(error.contains("locked"), "{error}");
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
assert_eq!(s.state.selected_keys, selected);
|
||||
assert_eq!(s.history.undo.len(), undo_len);
|
||||
s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap();
|
||||
let unlocked = s.project.to_json();
|
||||
s.execute("keys.setSpatialTangents", params).unwrap();
|
||||
let edited = s.project.to_json();
|
||||
assert_ne!(edited, unlocked);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), unlocked);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), edited);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_keys_or_motion_paths_cannot_edit_locked_targets() {
|
||||
for path_clip in [false, true] {
|
||||
for explicit in [false, true] {
|
||||
let (mut s, layer, opacity) = setup();
|
||||
let target_path = if path_clip { "transform/position" } else { "transform/opacity" };
|
||||
let source = if path_clip {
|
||||
s.execute("mask.new", json!({"layer": layer, "vertices": [[0, 0], [20, 10], [40, 0]], "closed": false})).unwrap();
|
||||
s.execute("prop.toggleAnimation", json!({"layer": layer, "path": "masks/#1/path"})).unwrap();
|
||||
s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop("masks/#1/path").unwrap().uid
|
||||
} else {
|
||||
opacity
|
||||
};
|
||||
s.execute("keys.select", json!({"keys": [{"layer": layer, "prop": source, "time": 0}]})).unwrap();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": layer, "path": target_path})).unwrap();
|
||||
s.execute("time.set", json!({"time": 3})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": true})).unwrap();
|
||||
let params = if explicit { json!({"layer": layer, "path": target_path}) } else { json!({}) };
|
||||
let before = s.project.to_json();
|
||||
let selected = s.state.selected_keys.clone();
|
||||
let undo_len = s.history.undo.len();
|
||||
let result = s.execute("keys.paste", params.clone());
|
||||
assert!(result.is_err(), "path={path_clip}, explicit={explicit}: {result:?}");
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
assert_eq!(s.state.selected_keys, selected);
|
||||
assert_eq!(s.history.undo.len(), undo_len);
|
||||
s.execute("layer.setSwitch", json!({"layers": [layer], "switch": "locked", "value": false})).unwrap();
|
||||
let unlocked = s.project.to_json();
|
||||
s.execute("keys.paste", params).unwrap();
|
||||
let pasted = s.project.to_json();
|
||||
assert_ne!(pasted, unlocked);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), unlocked);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), pasted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_paste_skips_locked_targets_without_changing_layer_pairing() {
|
||||
let (mut s, locked, locked_prop) = setup();
|
||||
let other = s.execute("layer.newSolid", json!({"name": "Other", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
for (time, value) in [(0, 100), (1, 80), (2, 60)] {
|
||||
s.execute("prop.addKey", json!({"layer": other, "path": "transform/opacity", "time": time, "value": value})).unwrap();
|
||||
}
|
||||
let other_prop = s.active_comp().unwrap().layer(LayerId(other)).unwrap().props.prop("transform/opacity").unwrap().uid;
|
||||
let locked_keys = keys_of(&s, locked, locked_prop);
|
||||
s.state.selected_keys = locked_keys.iter().chain(keys_of(&s, other, other_prop).iter()).copied().collect();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": true})).unwrap();
|
||||
s.execute("time.set", json!({"time": 3})).unwrap();
|
||||
let before = s.project.to_json();
|
||||
assert_eq!(s.execute("keys.paste", json!({"layers": [locked, other]})).unwrap(), json!(3), "only newly pasted keys are counted");
|
||||
assert_eq!(keys_of(&s, locked, locked_prop), locked_keys);
|
||||
assert!(locked_keys.iter().all(|k| s.state.selected_keys.contains(k)));
|
||||
assert_eq!(s.state.selected_keys.len(), 6);
|
||||
let keys = &s.active_comp().unwrap().layer(LayerId(other)).unwrap().props.find(other_prop).unwrap().keys;
|
||||
assert_eq!(keys.iter().filter(|k| k.time.seconds() >= 3.0).map(|k| k.value.as_f64()).collect::<Vec<_>>(), [100.0, 80.0, 60.0]);
|
||||
let pasted = s.project.to_json();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), pasted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_path_paste_preserves_locked_source_selection() {
|
||||
for explicit in [false, true] {
|
||||
let (mut s, source, _) = setup();
|
||||
s.execute("mask.new", json!({"layer": source, "vertices": [[0, 0], [20, 10], [40, 0]], "closed": false})).unwrap();
|
||||
s.execute("prop.toggleAnimation", json!({"layer": source, "path": "masks/#1/path"})).unwrap();
|
||||
let uid = s.active_comp().unwrap().layer(LayerId(source)).unwrap().props.prop("masks/#1/path").unwrap().uid;
|
||||
s.execute("keys.select", json!({"keys": [{"layer": source, "prop": uid, "time": 0}]})).unwrap();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
let locked_keys = s.state.selected_keys.clone();
|
||||
let target = s.execute("layer.newSolid", json!({"name": "Target", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.select", json!({"layer": target, "path": "transform/position"})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [source], "switch": "locked", "value": true})).unwrap();
|
||||
s.state.selected_keys = locked_keys.clone();
|
||||
let params = if explicit { json!({"layer": target, "path": "transform/position"}) } else { json!({}) };
|
||||
let before = s.project.to_json();
|
||||
assert_eq!(s.execute("keys.paste", params).unwrap(), json!(3));
|
||||
assert!(locked_keys.iter().all(|k| s.state.selected_keys.contains(k)));
|
||||
assert_eq!(s.state.selected_keys.len(), 4);
|
||||
assert_eq!(s.state.selected_keys.iter().filter(|k| k.layer == LayerId(target)).count(), 3);
|
||||
let pasted = s.project.to_json();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), before);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.project.to_json(), pasted);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_transform_preserves_locked_keys_in_a_mixed_selection() {
|
||||
let (mut s, locked, locked_prop) = setup();
|
||||
let unlocked = s.execute("layer.newSolid", json!({"name": "Unlocked", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
for t in 0..3 {
|
||||
s.execute("prop.addKey", json!({"layer": unlocked, "path": "transform/opacity", "time": t, "value": t * 25})).unwrap();
|
||||
}
|
||||
let unlocked_prop = s.active_comp().unwrap().layer(LayerId(unlocked)).unwrap().props.prop("transform/opacity").unwrap().uid;
|
||||
let locked_keys = keys_of(&s, locked, locked_prop);
|
||||
s.state.selected_keys = locked_keys.iter().chain(keys_of(&s, unlocked, unlocked_prop).iter()).copied().collect();
|
||||
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": true})).unwrap();
|
||||
let stored = |s: &Session, layer, prop| s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap().keys.clone();
|
||||
let before = stored(&s, locked, locked_prop);
|
||||
let undo = s.history.undo.len();
|
||||
for offset in [0.5, 1.0] {
|
||||
s.execute("keys.transform", json!({"timeOffset": offset, "valueOffset": offset * 10.0, "merge": "mixed-transform", "fromStart": true})).unwrap();
|
||||
assert_eq!(stored(&s, locked, locked_prop), before);
|
||||
assert_eq!(s.state.selected_keys.len(), 6);
|
||||
assert!(locked_keys.iter().all(|k| s.state.selected_keys.contains(k)), "locked selections stay at their original times");
|
||||
assert!(keys_of(&s, unlocked, unlocked_prop).iter().all(|k| s.state.selected_keys.contains(k)), "unlocked selections follow the keys");
|
||||
let moved = stored(&s, unlocked, unlocked_prop);
|
||||
for (i, key) in moved.iter().enumerate() {
|
||||
assert_eq!(key.time.seconds(), i as f64 + offset);
|
||||
assert_eq!(key.value.as_f64(), i as f64 * 25.0 + offset * 10.0);
|
||||
}
|
||||
}
|
||||
assert_eq!(s.history.undo.len(), undo + 1, "one merged drag");
|
||||
let after = stored(&s, unlocked, unlocked_prop);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(stored(&s, locked, locked_prop), before);
|
||||
assert_eq!(stored(&s, unlocked, unlocked_prop), before);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(stored(&s, locked, locked_prop), before);
|
||||
assert_eq!(stored(&s, unlocked, unlocked_prop), after);
|
||||
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": false})).unwrap();
|
||||
s.execute("keys.transform", json!({"timeOffset": 0.5, "valueOffset": 10})).unwrap();
|
||||
assert_ne!(stored(&s, locked, locked_prop), before, "unlocking makes the keys editable again");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -184,6 +184,51 @@ fn mask_interpolation_creates_matching_keys() {
|
||||
assert!(s.execute_checked("mask.interpolate", json!({"matchingMethod": "magic", "times": [0, 0.8], "layer": clip.0})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_interpolation_skips_locked_selection_and_rejects_explicit_locked_targets() {
|
||||
let (mut s, locked, _) = setup(patch_frames(rotating_pose));
|
||||
let unlocked = effectcraft_project::LayerId(s.execute("layer.newSolid", json!({"width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap());
|
||||
let a = ShapePath::rect([20.0, 20.0], 10.0, 10.0);
|
||||
let b = ShapePath::rect([40.0, 30.0], 20.0, 20.0);
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let mut selected = vec![];
|
||||
let mut masks = vec![];
|
||||
for layer in [locked, unlocked] {
|
||||
let uid = new_mask(&mut s, layer, &a);
|
||||
masks.push(uid);
|
||||
s.edit("keys", None, |project, _| {
|
||||
let pr = project.comp_mut(cid).unwrap().layer_mut(layer).unwrap().props.find_group_mut(uid).unwrap().get_mut("path").unwrap();
|
||||
pr.keys = vec![Keyframe::new(frame_time(0), KV::Path(a.clone())), Keyframe::new(frame_time(20), KV::Path(b.clone()))];
|
||||
selected.extend(pr.keys.iter().map(|key| crate::KeyRef { layer, prop: pr.uid, time: key.time }));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
s.execute("layer.setSwitch", json!({"layers": [locked.0], "switch": "locked", "value": true})).unwrap();
|
||||
s.state.selected_keys = selected.clone();
|
||||
let before = mask_path(&s, locked, masks[0]).keys;
|
||||
let options = serde_json::to_value(&s.state.mask_interp).unwrap();
|
||||
let project = serde_json::to_value(&*s.project).unwrap();
|
||||
let error = s.execute_checked("mask.interpolate", json!({"layer": locked.0, "mask": masks[0], "times": [0.0, 0.8], "quality": 75})).unwrap_err();
|
||||
assert!(error.to_string().contains("locked"), "{error}");
|
||||
assert_eq!(serde_json::to_value(&*s.project).unwrap(), project);
|
||||
assert_eq!(serde_json::to_value(&s.state.mask_interp).unwrap(), options);
|
||||
assert_eq!(s.state.selected_keys, selected);
|
||||
s.execute_checked("mask.interpolate", json!({})).unwrap();
|
||||
assert_eq!(mask_path(&s, locked, masks[0]).keys, before);
|
||||
assert_eq!(mask_path(&s, unlocked, masks[1]).keys.len(), 21);
|
||||
assert_eq!(s.state.selected_keys, selected);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(mask_path(&s, unlocked, masks[1]).keys.len(), 2);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(mask_path(&s, unlocked, masks[1]).keys.len(), 21);
|
||||
s.state.selected_keys.retain(|key| key.layer == locked);
|
||||
let selection = s.state.selected_keys.clone();
|
||||
assert!(s.execute_checked("mask.interpolate", json!({})).is_err());
|
||||
assert_eq!(mask_path(&s, locked, masks[0]).keys, before);
|
||||
assert_eq!(s.state.selected_keys, selection);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Warp Stabilizer
|
||||
|
||||
/// Camera offset on frame `f`: a slow pan plus jitter.
|
||||
|
||||
@@ -308,6 +308,45 @@ fn puppet_select_all_takes_the_pin_kind_and_several_pins_record_together() {
|
||||
assert!(s.execute("puppet.recordPin", json!({"layer": id, "pin": a, "pins": [st], "samples": samples})).is_err());
|
||||
}
|
||||
|
||||
/// Editing a parameter of one of several selected pins edits the same parameter of the others,
|
||||
/// in one undo step; unselected pins keep theirs (#1751).
|
||||
#[test]
|
||||
fn editing_one_selected_pin_edits_the_other_selected_pins() {
|
||||
let (mut s, id) = setup();
|
||||
let pin = |s: &mut Session, x: i64| s.execute("puppet.addPin", json!({"layer": id, "position": [x, 30]})).unwrap()["pin"].as_u64().unwrap();
|
||||
let (a, b, c) = (pin(&mut s, 10), pin(&mut s, 50), pin(&mut s, 90));
|
||||
let position = |s: &Session, pin: u64| layer(s, id).props.find_group(pin).unwrap().get("position").unwrap().clone();
|
||||
let (pa, pb) = (position(&s, a).uid, position(&s, b).uid);
|
||||
let at = |s: &Session, pin: u64| position(s, pin).value_at(Tick::ZERO).components();
|
||||
s.execute("prop.select", json!({"layer": id, "prop": a, "selectKeys": false})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": id, "prop": b, "selectKeys": false, "add": true})).unwrap();
|
||||
|
||||
// A typed value lands on both selected pins, and one Undo takes it back.
|
||||
let steps = s.history.undo.len();
|
||||
s.execute("prop.set", json!({"layer": id, "prop": pa, "value": [20, 40], "selected": true})).unwrap();
|
||||
assert_eq!((at(&s, a), at(&s, b), at(&s, c)), (vec![20.0, 40.0], vec![20.0, 40.0], vec![90.0, 30.0]));
|
||||
assert_eq!(s.history.undo.len(), steps + 1, "one undo step");
|
||||
s.undo();
|
||||
assert_eq!((at(&s, a), at(&s, b)), (vec![10.0, 30.0], vec![50.0, 30.0]));
|
||||
|
||||
// A scrub moves each selected pin by the same change from its own value.
|
||||
s.execute("prop.set", json!({"layer": id, "prop": pb, "value": [55, 25], "selected": true, "offset": true})).unwrap();
|
||||
assert_eq!((at(&s, a), at(&s, b), at(&s, c)), (vec![15.0, 25.0], vec![55.0, 25.0], vec![90.0, 30.0]));
|
||||
|
||||
// The stopwatch follows on both (a new pin starts keyed, so the toggle turns it off).
|
||||
let animated = |s: &Session| [a, b, c].map(|p| position(s, p).is_animated());
|
||||
assert_eq!(animated(&s), [true; 3]);
|
||||
s.execute("prop.toggleAnimation", json!({"layer": id, "prop": pa, "selected": true})).unwrap();
|
||||
assert_eq!(animated(&s), [false, false, true]);
|
||||
|
||||
// Without `selected`, or with only one pin selected, the edit stays on its own pin.
|
||||
s.execute("prop.set", json!({"layer": id, "prop": pa, "value": [0, 0]})).unwrap();
|
||||
assert_eq!(at(&s, b), vec![55.0, 25.0]);
|
||||
s.execute("prop.select", json!({"layer": id, "prop": a, "selectKeys": false})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": id, "prop": pa, "value": [5, 5], "selected": true})).unwrap();
|
||||
assert_eq!(at(&s, b), vec![55.0, 25.0]);
|
||||
}
|
||||
|
||||
fn expr_of(s: &Session, layer: u64, uid: u64) -> String {
|
||||
let l = layer_of(s, layer);
|
||||
l.props.find(uid).and_then(|p| p.expr.as_ref()).map(|e| e.text.clone()).unwrap_or_default()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -7,6 +7,7 @@ use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::commands::text_edit::{MAX_IME_TEXT_BYTES, ime_stamp_revision, ime_surrounding, layer_doc};
|
||||
use crate::tests::three_d::layer_count;
|
||||
|
||||
fn session(text: &str) -> (Session, u64) {
|
||||
let mut s = Session::default();
|
||||
@@ -1231,10 +1232,188 @@ fn deselect_all_keeps_an_empty_type_tool_layer() {
|
||||
s.state.text_edit.as_ref().is_some_and(|e| e.created && e.layer == effectcraft_project::LayerId(new)),
|
||||
"the Type tool opens the edit on its new, empty layer"
|
||||
);
|
||||
let count = |s: &Session| s.active_comp().map_or(0, |c| c.layers.len());
|
||||
let before = count(&s);
|
||||
let before = layer_count(&s);
|
||||
s.execute("edit.deselectAll", json!({})).unwrap();
|
||||
assert!(s.state.text_edit.is_none());
|
||||
assert!(s.state.selected_layers.is_empty());
|
||||
assert_eq!(count(&s), before, "an empty Type-tool layer is kept, as in After Effects");
|
||||
assert_eq!(layer_count(&s), before, "an empty Type-tool layer is kept, as in After Effects");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_ends_an_edit_on_already_committed_text() {
|
||||
// The engine route (MCP, control channel, script) with text that was committed before the
|
||||
// edit opened and nothing typed inside it: the edit still ends and the copy is selected.
|
||||
let (mut s, t) = editing("Hello", 5);
|
||||
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
|
||||
assert!(s.state.text_edit.is_none(), "the open edit ends on a direct edit.duplicate");
|
||||
assert_eq!(s.state.selected_layers, vec![effectcraft_project::LayerId(copy)], "and the copy is selected");
|
||||
assert_eq!(doc(&s, t).text, "Hello", "the committed text is kept on the original");
|
||||
assert_eq!(doc(&s, copy).text, "Hello", "and carried by the copy");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(layer_count(&s), 1, "undo removes the copy");
|
||||
assert!(s.state.text_edit.is_none(), "undo does not reopen the edit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_during_an_edit_copies_the_layer_not_a_selected_property_group() {
|
||||
use effectcraft_project::LayerId;
|
||||
// Opening the editor by command, as a Type-tool click does, keeps the property selection, so
|
||||
// an effect, shape item or text animator can still be selected while the user types.
|
||||
for what in ["effect", "shape item", "text animator"] {
|
||||
let (mut s, t) = session("Hello");
|
||||
let (owner, path) = match what {
|
||||
"effect" => {
|
||||
s.execute("effect.apply", json!({"layers": [t], "effect": "Gaussian Blur"})).unwrap();
|
||||
(t, "effects")
|
||||
}
|
||||
"shape item" => (s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap(), "contents"),
|
||||
_ => {
|
||||
s.execute("layer.addTextAnimator", json!({"layer": t, "properties": ["opacity"]})).unwrap();
|
||||
(t, "text/animators")
|
||||
}
|
||||
};
|
||||
let items =
|
||||
|s: &Session| s.active_comp().unwrap().layer(LayerId(owner)).unwrap().props.group(path).unwrap().groups().map(|g| g.uid).collect::<Vec<_>>();
|
||||
let item = items(&s)[0];
|
||||
let n = items(&s).len();
|
||||
let before = layer_count(&s);
|
||||
s.execute("prop.select", json!({"layer": owner, "prop": item})).unwrap();
|
||||
begin_edit_at(&mut s, t, 5);
|
||||
let r = s.execute("edit.duplicate", json!({})).unwrap();
|
||||
assert!(s.state.text_edit.is_none(), "{what}: Duplicate ends the open edit");
|
||||
assert_eq!(items(&s).len(), n, "{what}: the selected {what} is not duplicated");
|
||||
assert_eq!(layer_count(&s), before + 1, "{what}: the edited layer is");
|
||||
assert_eq!(s.state.selected_layers, vec![LayerId(r[0].as_u64().unwrap())], "{what}: and its copy is selected");
|
||||
// The selection the edit passed over goes with it: the next Duplicate copies the selected
|
||||
// copy rather than reaching back to the original's {what}.
|
||||
s.execute("edit.duplicate", json!({})).unwrap();
|
||||
assert_eq!(items(&s).len(), n, "{what}: a second Duplicate leaves the original's {what} alone");
|
||||
assert_eq!(layer_count(&s), before + 2, "{what}: and copies the copy");
|
||||
// With no edit open, the selected item keeps its precedence over the layers.
|
||||
s.execute("prop.select", json!({"layer": owner, "prop": item})).unwrap();
|
||||
s.execute("edit.duplicate", json!({})).unwrap();
|
||||
assert_eq!(items(&s).len(), n + 1, "{what}: outside an edit Duplicate copies the selected {what}");
|
||||
assert_eq!(layer_count(&s), before + 2, "{what}: and no layer");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_during_an_edit_leaves_no_key_or_vertex_selected_on_the_original() {
|
||||
use effectcraft_project::LayerId;
|
||||
// Opening the editor keeps the keyframe and mask-vertex selection too. Once the copy takes the
|
||||
// selection, Clear deletes the copy, not a key or a vertex of the original.
|
||||
for what in ["keyframe", "mask vertex"] {
|
||||
let (mut s, t) = session("Hello");
|
||||
let mask = match what {
|
||||
"keyframe" => {
|
||||
for time in [0.0, 1.0] {
|
||||
s.execute("prop.addKey", json!({"layer": t, "path": "transform/opacity", "time": time, "value": 50})).unwrap();
|
||||
}
|
||||
s.execute("keys.select", json!({"keys": [{"layer": t, "path": "transform/opacity", "time": 0.0}]})).unwrap();
|
||||
None
|
||||
}
|
||||
// The Pen leaves the new mask's last vertex selected.
|
||||
_ => Some(s.execute("mask.new", json!({"layer": t, "vertices": [[0, 0], [40, 0], [40, 40]], "closed": true})).unwrap()["mask"].as_u64().unwrap()),
|
||||
};
|
||||
let original = |s: &Session| {
|
||||
let l = s.active_comp().unwrap().layer(LayerId(t)).unwrap();
|
||||
match mask {
|
||||
None => l.props.prop("transform/opacity").unwrap().keys.len(),
|
||||
Some(m) => l.props.find_group(m).unwrap().get("path").unwrap().value.as_path().unwrap().vertices.len(),
|
||||
}
|
||||
};
|
||||
let (n, before) = (original(&s), layer_count(&s));
|
||||
begin_edit_at(&mut s, t, 5);
|
||||
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
|
||||
assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "{what}: the copy is selected");
|
||||
s.execute("edit.clear", json!({})).unwrap();
|
||||
assert_eq!(layer_count(&s), before, "{what}: Clear deletes the selected copy");
|
||||
assert_eq!(original(&s), n, "{what}: and leaves the original's {what} alone");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_during_an_edit_copies_the_edited_layer_after_another_is_selected() {
|
||||
use effectcraft_project::LayerId;
|
||||
// An engine route (MCP, control channel, script) can select another layer while the edit
|
||||
// stays open. The implicit Duplicate still means the edited layer, as Clear does; a named
|
||||
// target keeps its meaning.
|
||||
let (mut s, t) = session("Hello");
|
||||
let other = s.execute("layer.newText", json!({"text": "Other"})).unwrap()["layer"].as_u64().unwrap();
|
||||
begin_edit_at(&mut s, t, 5);
|
||||
s.execute("layer.select", json!({"layer": other})).unwrap();
|
||||
assert!(s.state.text_edit.as_ref().is_some_and(|e| e.layer == LayerId(t)), "selecting another layer leaves the edit open");
|
||||
let before = layer_count(&s);
|
||||
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
|
||||
assert!(s.state.text_edit.is_none(), "Duplicate ends the open edit");
|
||||
assert_eq!(layer_count(&s), before + 1, "one layer is copied");
|
||||
assert_eq!(doc(&s, copy).text, "Hello", "the edited layer is copied, not the newly selected one");
|
||||
assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "and its copy is selected");
|
||||
// Null parameters name no layer, as everywhere else layers are read.
|
||||
for unnamed in [json!({"layers": null}), json!({"layer": null})] {
|
||||
begin_edit_at(&mut s, t, 5);
|
||||
s.execute("layer.select", json!({"layer": other})).unwrap();
|
||||
let copy = s.execute("edit.duplicate", unnamed.clone()).unwrap()[0].as_u64().unwrap();
|
||||
assert_eq!(doc(&s, copy).text, "Hello", "{unnamed}: the edited layer is copied");
|
||||
}
|
||||
begin_edit_at(&mut s, t, 5);
|
||||
let named = s.execute("edit.duplicate", json!({"layer": other})).unwrap()[0].as_u64().unwrap();
|
||||
assert_eq!(doc(&s, named).text, "Other", "a named layer is copied as named");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_during_an_edit_with_nothing_selected_copies_the_edited_layer() {
|
||||
use effectcraft_project::LayerId;
|
||||
// An engine route can also clear the selection while the edit stays open. Duplicate stays
|
||||
// available, since its unnamed target is the edited layer, not the selection.
|
||||
let (mut s, _) = editing("Hello", 5);
|
||||
s.execute("layer.select", json!({"layers": []})).unwrap();
|
||||
assert!(s.state.selected_layers.is_empty() && s.state.text_edit.is_some(), "the edit is open with nothing selected");
|
||||
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
|
||||
assert!(s.state.text_edit.is_none(), "Duplicate ends the open edit");
|
||||
assert_eq!(doc(&s, copy).text, "Hello", "and copies the edited layer");
|
||||
assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "which is selected");
|
||||
// With no edit open, nothing selected still means nothing to duplicate.
|
||||
s.execute("layer.select", json!({"layers": []})).unwrap();
|
||||
let before = layer_count(&s);
|
||||
assert!(s.execute("edit.duplicate", json!({})).is_err(), "outside an edit Duplicate needs a selection");
|
||||
assert_eq!(layer_count(&s), before, "and copies nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_ends_the_open_edit_and_selects_the_copy() {
|
||||
let (mut s, t) = editing("Hello", 5);
|
||||
s.execute("text.insert", json!({"text": "!"})).unwrap();
|
||||
assert_eq!(doc(&s, t).text, "Hello!");
|
||||
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
|
||||
// A deselected layer never keeps an open text edit: Duplicate commits the edit through the
|
||||
// same route as text.endEdit before the selection moves, so every route (menu, shortcut,
|
||||
// palette, MCP, script) leaves one final state.
|
||||
assert!(s.state.text_edit.is_none(), "Duplicate ends the open text edit, as Deselect All does");
|
||||
assert_eq!(s.state.selected_layers, vec![effectcraft_project::LayerId(copy)], "and selects the copy");
|
||||
assert_eq!(doc(&s, t).text, "Hello!", "the original keeps the typed text");
|
||||
assert_eq!(doc(&s, copy).text, "Hello!", "the copy carries it too");
|
||||
// The edit ending is no undoable step of its own (as with text.endEdit): undo takes the
|
||||
// duplicate, it does not reopen the edit or undo the typing.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(layer_count(&s), 1, "undo removed the copy");
|
||||
assert_eq!(doc(&s, t).text, "Hello!", "the typed text stays committed");
|
||||
assert!(s.state.text_edit.is_none(), "undo does not reopen the edit");
|
||||
// With no open edit Duplicate is untouched by all of this: same behavior, no extra steps.
|
||||
s.execute("edit.duplicate", json!({"layers": [t]})).unwrap();
|
||||
assert_eq!(layer_count(&s), 2, "a plain duplicate still duplicates");
|
||||
assert!(s.state.text_edit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_of_an_empty_type_tool_layer_ends_the_edit_and_copies_it() {
|
||||
use effectcraft_project::LayerId;
|
||||
let (mut s, _keep) = session("Hello");
|
||||
let new = s.execute("layer.newText", json!({"text": "", "position": [80.0, 80.0], "edit": true})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert!(s.state.text_edit.as_ref().is_some_and(|e| e.created && e.layer == LayerId(new)), "the Type tool opens the edit on its new, empty layer");
|
||||
let before = layer_count(&s);
|
||||
let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
|
||||
assert!(s.state.text_edit.is_none(), "the edit ends the way text.endEdit ends it");
|
||||
assert_eq!(layer_count(&s), before + 1, "the empty layer is kept, as text.endEdit keeps it, and copied");
|
||||
assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "and the copy is selected");
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@ fn auto_orient_command() {
|
||||
assert!(!effectcraft_render::three_d::camera::is_two_node(&s.active_comp().unwrap().layers[0]));
|
||||
}
|
||||
|
||||
fn layer_count(s: &Session) -> usize {
|
||||
/// Layers in the active comp.
|
||||
pub(crate) fn layer_count(s: &Session) -> usize {
|
||||
s.active_comp().unwrap().layers.len()
|
||||
}
|
||||
|
||||
|
||||
@@ -298,6 +298,25 @@ fn interpolation_dialog_temporal_spatial_and_roving() {
|
||||
assert_ne!(prop(&s, l, "transform/position").keys[0].out_interp, Interp::Hold);
|
||||
}
|
||||
|
||||
/// Editing another key's value or time keeps the roving key at its share of the path (#1284: the
|
||||
/// roving key went back to where it was after edits to other keys).
|
||||
#[test]
|
||||
fn roving_key_follows_value_and_time_edits_to_other_keys() {
|
||||
let (mut s, l) = setup();
|
||||
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0])), (0.3, json!([300, 0])), (2.0, json!([400, 0]))]);
|
||||
let uid = prop(&s, l, "transform/position").uid;
|
||||
s.execute("keys.select", json!({"keys": [{"layer": l, "prop": uid, "time": 0.3}]})).unwrap();
|
||||
s.execute("keys.interpolation", json!({"roving": true})).unwrap();
|
||||
let roving_at = |s: &Session| prop(s, l, "transform/position").keys[1].time.seconds();
|
||||
assert!((roving_at(&s) - 1.5).abs() < 0.01, "start: {}", roving_at(&s));
|
||||
// The last key's value moves to 800 px: the path is 300 + 500 px, so the roving key is at 300 / 800.
|
||||
s.execute("keys.set", json!({"layer": l, "path": "transform/position", "time": 2.0, "value": [800, 0]})).unwrap();
|
||||
assert!((roving_at(&s) - 0.75).abs() < 0.02, "value edit: {}", roving_at(&s));
|
||||
// The last key moves to 3 s: the same 300 / 800 share of 3 s.
|
||||
s.execute("keys.set", json!({"layer": l, "path": "transform/position", "time": 2.0, "newTime": 3.0})).unwrap();
|
||||
assert!((roving_at(&s) - 1.125).abs() < 0.02, "time edit: {}", roving_at(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_editor_key_and_handle_edits() {
|
||||
let (mut s, l) = setup();
|
||||
@@ -325,6 +344,34 @@ fn graph_editor_key_and_handle_edits() {
|
||||
assert_eq!(prop(&s, l, "transform/opacity").keys[0].out_interp, Interp::Linear);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_editor_ease_edits_reject_a_locked_layer() {
|
||||
let (mut s, l) = setup();
|
||||
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (2.0, json!(0))]);
|
||||
select_prop_keys(&mut s, l, "transform/opacity");
|
||||
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "locked", "value": true})).unwrap();
|
||||
let before = prop(&s, l, "transform/opacity");
|
||||
let selected = s.state.selected_keys.clone();
|
||||
let undo = s.history.undo.len();
|
||||
for side in ["in", "out"] {
|
||||
let error =
|
||||
s.execute("keys.setEase", json!({"layer": l, "path": "transform/opacity", "time": 1.0, "side": side, "speed": 200, "influence": 60})).unwrap_err();
|
||||
assert!(error.to_string().contains("locked"), "{error}");
|
||||
assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys);
|
||||
assert_eq!(s.state.selected_keys, selected);
|
||||
assert_eq!(s.history.undo.len(), undo);
|
||||
}
|
||||
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "locked", "value": false})).unwrap();
|
||||
s.execute("keys.setEase", json!({"layer": l, "path": "transform/opacity", "time": 1.0, "side": "out", "speed": 200, "influence": 60})).unwrap();
|
||||
let after = prop(&s, l, "transform/opacity");
|
||||
assert_eq!(after.keys[1].out_interp, Interp::Bezier);
|
||||
assert_eq!(after.keys[1].out_ease[0].speed, 200.0);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(prop(&s, l, "transform/opacity").keys, before.keys);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(prop(&s, l, "transform/opacity").keys, after.keys);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_expression_to_keyframes_on_a_reversed_layer_keeps_values() {
|
||||
let (mut s, l) = setup();
|
||||
@@ -1483,3 +1530,29 @@ fn a_typed_component_applies_only_that_component_to_the_selected_layers() {
|
||||
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [1.0, 2.0, 3.0], "selected": true})).unwrap();
|
||||
assert_eq!(pos(&s, c), vec![1.0, 2.0, 3.0]);
|
||||
}
|
||||
|
||||
/// #2485: linked Audio Levels typed with several layers selected: each takes the typed Left, and
|
||||
/// its Right moves by its own change, so every layer keeps its own stereo balance.
|
||||
#[test]
|
||||
fn a_typed_component_moves_its_follow_components_by_each_layers_own_change() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Sound", "width": 64, "height": 36, "duration": 4})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap();
|
||||
let a = s.execute("layer.addItem", json!({"item": "Sound"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let b = s.execute("layer.addItem", json!({"item": "Sound"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": a, "path": "audio/levels", "value": [-6.0, -12.0]})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": b, "path": "audio/levels", "value": [-20.0, -30.0]})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [a, b]})).unwrap();
|
||||
let uid = prop(&s, a, "audio/levels").uid;
|
||||
let undo = s.history.undo.len();
|
||||
s.execute_checked("prop.set", json!({"layer": a, "prop": uid, "value": [-3.0, -9.0], "selected": true, "dims": [0], "follow": [1]})).unwrap();
|
||||
assert_eq!(prop(&s, a, "audio/levels").value.components(), vec![-3.0, -9.0]);
|
||||
assert_eq!(prop(&s, b, "audio/levels").value.components(), vec![-3.0, -13.0], "B rises 17 dB on both channels");
|
||||
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
|
||||
|
||||
// Keyed at a time other than the CTI: B's change is measured on its key there.
|
||||
animate(&mut s, b, "audio/levels", &[(0.0, json!([-20.0, -30.0])), (1.0, json!([-10.0, -40.0]))]);
|
||||
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [-4.0, -10.0], "time": 1.0, "selected": true, "dims": [0], "follow": [1]})).unwrap();
|
||||
let keys: Vec<Vec<f64>> = prop(&s, b, "audio/levels").keys.iter().map(|k| k.value.components()).collect();
|
||||
assert_eq!(keys, vec![vec![-20.0, -30.0], vec![-4.0, -34.0]], "B's 1 s key rises 6 dB on both channels");
|
||||
}
|
||||
|
||||
@@ -52,6 +52,14 @@ fn export_and_reimport_timeline_commands() {
|
||||
let r = s.execute_checked("file.exportTimeline", json!({"comp": main, "path": tmp("NoExt"), "format": "otio", "prerender": "none"})).unwrap();
|
||||
assert!(r["path"].as_str().unwrap().ends_with("NoExt.otio"));
|
||||
assert!(s.execute_checked("file.exportTimeline", json!({"path": tmp("x.xml"), "prerender": "sometimes"})).is_err());
|
||||
// In the background (the export dialog, #2269): a job, then the same file and result.
|
||||
let path = tmp("Background.xml");
|
||||
let r = s.execute_checked("file.exportTimeline", json!({"comp": main, "path": path, "background": true})).unwrap();
|
||||
assert!(r["job"].is_string(), "{r}");
|
||||
s.wait_jobs();
|
||||
let done = s.job_log.last().unwrap();
|
||||
assert_eq!((done.status.as_str(), done.result["path"].as_str()), ("done", Some(path.as_str())));
|
||||
assert!(std::fs::read_to_string(&path).unwrap().contains("<xmeml"));
|
||||
assert!(s.execute_checked("file.importTimeline", json!({"path": "/nope/a.prproj"})).is_err());
|
||||
let formats = s.execute("file.timelineFormats", json!({})).unwrap();
|
||||
assert_eq!(formats.as_array().unwrap().len(), 6);
|
||||
|
||||
@@ -510,6 +510,12 @@ fn layer_styles_collapsed_precomps_and_cpu_fallbacks() {
|
||||
adj.switches.adjustment = true;
|
||||
s.effect(&mut adj, "ec.channel.invert", &[]);
|
||||
s.push(adj);
|
||||
// An adjustment layer in another blend mode blends the adjusted comp in that mode (#2618).
|
||||
let mut adj = s.solid([1.0, 1.0, 1.0], 70, 40);
|
||||
adj.switches.adjustment = true;
|
||||
adj.blend_mode = BlendMode::Screen;
|
||||
set(&mut adj, "transform/opacity", Value::Scalar(60.0));
|
||||
s.push(adj);
|
||||
check(&format!("collapse + adjustment {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,10 +182,8 @@ fn violations(src: &str) -> Vec<(usize, String)> {
|
||||
let tok = next_token(&s, &mut i);
|
||||
match tok {
|
||||
Tok::Open => levels.push(vec![]),
|
||||
Tok::Close => {
|
||||
if levels.len() > 1 {
|
||||
levels.pop();
|
||||
}
|
||||
Tok::Close if levels.len() > 1 => {
|
||||
levels.pop();
|
||||
}
|
||||
Tok::Sep => {
|
||||
if let Some(l) = levels.last_mut() {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Accessibility helpers (docs/gaps.md gap 25): names, roles and states for the widgets the app
|
||||
//! paints itself, and the small pieces of list keyboard handling the panels share.
|
||||
//!
|
||||
//! egui builds an AccessKit node for every widget that can take the keyboard focus, but a widget
|
||||
//! made with `ui.interact` has no name and no role until the panel says what it is. These helpers
|
||||
//! write that onto the widget's node *after* the widget is made each frame (egui rebuilds the
|
||||
//! tree every pass), and keep whatever egui already put there (its role for a `DragValue`, the
|
||||
//! numeric value of a `Slider`).
|
||||
//!
|
||||
//! Names are the same translated strings the panel draws (`i18n::tr`), never hard-coded English.
|
||||
//! `tests/ui_accessibility_audit.rs` walks the tree and fails on an unnamed control in the panels
|
||||
//! that have had their pass.
|
||||
|
||||
use egui::accesskit::{HasPopup, Role, Toggled};
|
||||
use egui::{Context, Id, Response};
|
||||
|
||||
/// Set `f` on `r`'s AccessKit node (nothing when AccessKit is off). egui only fills a node's
|
||||
/// bounds and actions for widgets that can take the keyboard focus; a widget that cannot (the
|
||||
/// non-current rows of a list) still needs both, or assistive technology cannot place or click it.
|
||||
fn node(r: &Response, f: impl FnOnce(&mut egui::accesskit::Node)) {
|
||||
r.ctx.accesskit_node_builder(r.id, |n| {
|
||||
n.set_bounds(egui::accesskit::Rect { x0: r.rect.min.x.into(), y0: r.rect.min.y.into(), x1: r.rect.max.x.into(), y1: r.rect.max.y.into() });
|
||||
if r.sense.is_focusable() {
|
||||
n.add_action(egui::accesskit::Action::Focus);
|
||||
}
|
||||
if r.sense.senses_click() {
|
||||
n.add_action(egui::accesskit::Action::Click);
|
||||
}
|
||||
f(n);
|
||||
});
|
||||
}
|
||||
|
||||
/// Give `r`'s node a role and leave its name to the caller.
|
||||
pub(crate) fn role(r: &Response, role: Role) {
|
||||
node(r, |n| n.set_role(role));
|
||||
}
|
||||
|
||||
/// Give `r`'s node a role and a name.
|
||||
pub(crate) fn role_name(r: &Response, role: Role, label: &str) {
|
||||
node(r, |n| {
|
||||
n.set_role(role);
|
||||
n.set_label(label);
|
||||
});
|
||||
}
|
||||
|
||||
/// Name `r`'s node (`label`), keeping the role egui gave it (spin button, slider, text input).
|
||||
pub(crate) fn name(r: &Response, label: &str) {
|
||||
node(r, |n| n.set_label(label));
|
||||
}
|
||||
|
||||
/// Give `r`'s node a current value and keep the name it already has.
|
||||
pub(crate) fn value(r: &Response, value: &str) {
|
||||
node(r, |n| n.set_value(value));
|
||||
}
|
||||
|
||||
/// Name `r`'s node and give it a current value (a spin button's or a text field's text).
|
||||
pub(crate) fn name_value(r: &Response, label: &str, value: &str) {
|
||||
node(r, |n| {
|
||||
n.set_label(label);
|
||||
n.set_value(value);
|
||||
});
|
||||
}
|
||||
|
||||
/// A push button; `enabled` false marks it disabled for assistive technology (it stays visible).
|
||||
pub(crate) fn button(r: &Response, label: &str, enabled: bool) {
|
||||
node(r, |n| {
|
||||
n.set_role(Role::Button);
|
||||
n.set_label(label);
|
||||
if !enabled {
|
||||
n.set_disabled();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A button that opens a menu or popup: `open` is whether it is open now.
|
||||
pub(crate) fn menu_button(r: &Response, label: &str, open: bool) {
|
||||
node(r, |n| {
|
||||
n.set_role(Role::Button);
|
||||
n.set_label(label);
|
||||
n.set_has_popup(HasPopup::Menu);
|
||||
n.set_expanded(open);
|
||||
});
|
||||
}
|
||||
|
||||
/// A button that shows or hides something (a twirl): `expanded` is the state it leads to from.
|
||||
pub(crate) fn disclosure(r: &Response, label: &str, expanded: bool) {
|
||||
node(r, |n| {
|
||||
n.set_role(Role::Button);
|
||||
n.set_label(label);
|
||||
n.set_expanded(expanded);
|
||||
});
|
||||
}
|
||||
|
||||
/// A check box.
|
||||
pub(crate) fn check_box(r: &Response, label: &str, checked: bool, enabled: bool) {
|
||||
node(r, |n| {
|
||||
n.set_role(Role::CheckBox);
|
||||
n.set_label(label);
|
||||
n.set_toggled(if checked { Toggled::True } else { Toggled::False });
|
||||
if !enabled {
|
||||
n.set_disabled();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A drop-down: `label` says what it sets and `value` is what it is set to.
|
||||
pub(crate) fn combo_box(r: &Response, label: &str, value: &str, enabled: bool) {
|
||||
node(r, |n| {
|
||||
n.set_role(Role::ComboBox);
|
||||
n.set_label(label);
|
||||
n.set_value(value);
|
||||
n.set_has_popup(HasPopup::Menu);
|
||||
n.set_expanded(false);
|
||||
if !enabled {
|
||||
n.set_disabled();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A row of a tree: selected, its depth (`level`, 1 at the top), whether it is open (`None` for a
|
||||
/// leaf) and its place among its siblings.
|
||||
pub(crate) fn tree_item(r: &Response, label: &str, selected: bool, level: usize, expanded: Option<bool>) {
|
||||
node(r, |n| {
|
||||
n.set_role(Role::TreeItem);
|
||||
n.set_label(label);
|
||||
n.set_selected(selected);
|
||||
n.set_level(level);
|
||||
if let Some(open) = expanded {
|
||||
n.set_expanded(open);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// An entry of a flat list: `position` is its 1-based place among `size`.
|
||||
pub(crate) fn list_item(r: &Response, label: &str, selected: bool, position: usize, size: usize) {
|
||||
node(r, |n| {
|
||||
n.set_role(Role::ListItem);
|
||||
n.set_label(label);
|
||||
n.set_selected(selected);
|
||||
n.set_position_in_set(position);
|
||||
n.set_size_of_set(size);
|
||||
});
|
||||
}
|
||||
|
||||
/// An entry of a menu or popup list; `checked` is `Some` for an entry that is on or off.
|
||||
pub(crate) fn menu_item(r: &Response, label: &str, checked: Option<bool>) {
|
||||
node(r, |n| {
|
||||
n.set_label(label);
|
||||
match checked {
|
||||
Some(on) => {
|
||||
n.set_role(Role::MenuItemCheckBox);
|
||||
n.set_toggled(if on { Toggled::True } else { Toggled::False });
|
||||
}
|
||||
None => n.set_role(Role::MenuItem),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A text field with a name (and its text as the value).
|
||||
pub(crate) fn text_input(r: &Response, label: &str, text: &str) {
|
||||
node(r, |n| {
|
||||
n.set_role(Role::TextInput);
|
||||
n.set_label(label);
|
||||
n.set_value(text);
|
||||
});
|
||||
}
|
||||
|
||||
/// A progress bar: `fraction` 0..=1 and the sentence it shows.
|
||||
pub(crate) fn progress(r: &Response, label: &str, fraction: f32) {
|
||||
node(r, |n| {
|
||||
n.set_role(Role::ProgressIndicator);
|
||||
n.set_label(label);
|
||||
n.set_min_numeric_value(0.0);
|
||||
n.set_max_numeric_value(1.0);
|
||||
n.set_numeric_value(f64::from(fraction.clamp(0.0, 1.0)));
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------
|
||||
// List keyboard handling
|
||||
|
||||
/// Ask for the keyboard focus on widget `id` once it is drawn (a list moves its focus to the row
|
||||
/// the arrow keys reached, which may not be focusable, or drawn, until the next pass). `key`
|
||||
/// names the list.
|
||||
pub(crate) fn focus_next_pass(ctx: &Context, key: &str, id: Id) {
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
ctx.data_mut(|d| d.insert_temp(Id::unique(("a11y-pending-focus", key)), (id, pass)));
|
||||
}
|
||||
|
||||
/// True once, for the widget `focus_next_pass` named for list `key`: the caller then requests the
|
||||
/// focus. A request nobody answers within a few passes is dropped.
|
||||
pub(crate) fn take_pending_focus(ctx: &Context, key: &str, id: Id) -> bool {
|
||||
let slot = Id::unique(("a11y-pending-focus", key));
|
||||
let now = ctx.cumulative_pass_nr();
|
||||
let Some((want, at)) = ctx.data(|d| d.get_temp::<(Id, u64)>(slot)) else { return false };
|
||||
if now > at.saturating_add(4) {
|
||||
ctx.data_mut(|d| d.remove::<(Id, u64)>(slot));
|
||||
return false;
|
||||
}
|
||||
if want != id || now == at {
|
||||
return false;
|
||||
}
|
||||
ctx.data_mut(|d| d.remove::<(Id, u64)>(slot));
|
||||
true
|
||||
}
|
||||
@@ -90,7 +90,7 @@ impl EffectcraftApp {
|
||||
|
||||
/// Apply the engine events drained this tick: open a comp, show a toast, open a URL, run a frontend command,
|
||||
/// purge frame caches or copy an image; command and clipboard failures go to `app.ui.status`.
|
||||
pub(super) fn handle_events(&mut self, ctx: &egui::Context) {
|
||||
pub(crate) fn handle_events(&mut self, ctx: &egui::Context) {
|
||||
for ev in self.session.drain_events() {
|
||||
match ev {
|
||||
effectcraft_engine::Event::OpenComp(c) => {
|
||||
@@ -98,7 +98,7 @@ impl EffectcraftApp {
|
||||
self.ui.timeline.pps = None;
|
||||
}
|
||||
effectcraft_engine::Event::Toast { message, .. } => self.toast = Some((message, self.now)),
|
||||
effectcraft_engine::Event::OpenUrl(url) => open_url(ctx, url),
|
||||
effectcraft_engine::Event::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
|
||||
// Frames are keyed by content: an edit keeps those of comps it doesn't touch.
|
||||
effectcraft_engine::Event::ProjectChanged { .. } => {}
|
||||
effectcraft_engine::Event::Frontend { command, params } => {
|
||||
@@ -411,6 +411,8 @@ impl EffectcraftApp {
|
||||
header::show(self, ui, header);
|
||||
let body = egui::Rect::from_min_max(egui::pos2(full.min.x + 4.0, header.max.y + 2.0), egui::pos2(full.max.x - 4.0, full.max.y - 4.0));
|
||||
crate::color_picker::note_dialog(&ctx, self.dialog);
|
||||
// After the shortcuts, menus and header, which can replace the project (New, Open).
|
||||
self.ui.timeline.scope_links(self.session.script_ui.project_epoch);
|
||||
self.dock_area(ui, body);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
prefs_live::dropped_files(self, ui, full);
|
||||
@@ -581,48 +583,3 @@ impl EffectcraftApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens `url`: on Windows a `file://` folder (Reveal in Explorer) in File Explorer, since the
|
||||
/// system browser would get it otherwise (#1961); anything else in the browser.
|
||||
fn open_url(ctx: &egui::Context, url: String) {
|
||||
#[cfg(windows)]
|
||||
if let Some(path) = file_url_path(&url) {
|
||||
// The engine only sends folders it checked are plain folders (`reveal_url`).
|
||||
if std::process::Command::new("explorer").arg(&path).spawn().is_ok() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
ctx.open_url(egui::OpenUrl::new_tab(url));
|
||||
}
|
||||
|
||||
/// The Windows path of a `file:///C:/…` (or `file:////server/share/…`) URL, percent-decoded.
|
||||
#[cfg(any(windows, test))]
|
||||
fn file_url_path(url: &str) -> Option<String> {
|
||||
let rest = url.strip_prefix("file:///")?;
|
||||
let mut bytes = Vec::with_capacity(rest.len());
|
||||
let mut it = rest.bytes();
|
||||
while let Some(b) = it.next() {
|
||||
if b == b'%' {
|
||||
let hex = [it.next()?, it.next()?];
|
||||
bytes.push(u8::from_str_radix(std::str::from_utf8(&hex).ok()?, 16).ok()?);
|
||||
} else {
|
||||
bytes.push(b);
|
||||
}
|
||||
}
|
||||
let path = String::from_utf8(bytes).ok()?.replace('/', "\\");
|
||||
// `file:////server/share` is the UNC path `\\server\share`.
|
||||
Some(if path.starts_with('\\') { format!("\\{path}") } else { path })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// #1961: Reveal's `file://` URLs map back to the folder Explorer opens.
|
||||
#[test]
|
||||
fn file_urls_map_to_windows_paths() {
|
||||
assert_eq!(super::file_url_path("file:///C:/Footage/My%20Shots/").as_deref(), Some("C:\\Footage\\My Shots\\"));
|
||||
assert_eq!(super::file_url_path("file:///C:/Clips/%E6%98%A0%E5%83%8F").as_deref(), Some("C:\\Clips\\映像"));
|
||||
assert_eq!(super::file_url_path("file:////server/share/x").as_deref(), Some("\\\\server\\share\\x"));
|
||||
assert_eq!(super::file_url_path("https://getartcraft.com"), None);
|
||||
assert_eq!(super::file_url_path("file:///C:/bad%2"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,7 +406,7 @@ impl EffectcraftApp {
|
||||
|
||||
/// A press in a panel (its body, tab or group header) focuses it; another Composition viewer
|
||||
/// also becomes the active one.
|
||||
fn focus_panel(&mut self, ctx: &egui::Context, p: PanelKind) {
|
||||
pub(crate) fn focus_panel(&mut self, ctx: &egui::Context, p: PanelKind) {
|
||||
self.ui.focused = p;
|
||||
panels::viewer_text::panel_clicked(self, ctx, p);
|
||||
if let Some(id) = panels::viewers::id_of(p) {
|
||||
|
||||
@@ -226,7 +226,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");
|
||||
|
||||
@@ -1907,7 +1907,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[
|
||||
("layer.newShape", "Shape Layer", "シェイプレイヤー"),
|
||||
("layer.newAdjustment", "Adjustment Layer", "調整レイヤー"),
|
||||
("layer.newContentAwareFill", "Content-Aware Fill Layer...", "コンテンツに応じた塗りつぶしレイヤー..."),
|
||||
("layer.newPrimitive", "3D Primitive...", "3D プリミティブ..."),
|
||||
("layer.newPrimitive", "3D Primitive...", "3Dプリミティブ..."),
|
||||
("layer.new3dPrimitive", "Cube", "立方体"),
|
||||
("layer.new3dPrimitive", "Sphere", "球体"),
|
||||
("layer.new3dPrimitive", "Plane", "平面"),
|
||||
@@ -2206,9 +2206,9 @@ const JAPANESE: &[(&str, &str, &str)] = &[
|
||||
("view.zoomOut", "Zoom Out", "ズームアウト"),
|
||||
("", "Resolution", "解像度"),
|
||||
("view.res.full", "Full", "フル画質"),
|
||||
("view.res.half", "Half", "1/2 画質"),
|
||||
("view.res.third", "Third", "1/3 画質"),
|
||||
("view.res.quarter", "Quarter", "1/4 画質"),
|
||||
("view.res.half", "Half", "1/2画質"),
|
||||
("view.res.third", "Third", "1/3画質"),
|
||||
("view.res.quarter", "Quarter", "1/4画質"),
|
||||
("view.res.custom", "Custom...", "カスタム..."),
|
||||
("view.displayColorManagement", "Use Display Color Management", "ディスプレイカラーマネジメントを使用"),
|
||||
("", "Simulate Output", "出力をシミュレート"),
|
||||
|
||||
+104
-42
@@ -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", "ეფექტებისა და პრესეტების პარამეტრები"),
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ fn reveal_targets(app: &EffectcraftApp) -> Vec<u64> {
|
||||
|
||||
/// The arrow keys nudge the selected layers 1 pixel at the viewer's magnification (`up`,
|
||||
/// `down`, `left`, `right`), 10 with Shift (`up10`…), as After Effects does, so zoomed in
|
||||
/// they move by fractions of a comp pixel. From any panel except Project (which owns the
|
||||
/// they move by fractions of a comp pixel. From any panel except Project, Effects & Presets and Render Queue (which own the
|
||||
/// arrow keys for its own keyboard navigation, #961); not while typing, with a dialog or
|
||||
/// menu open. With mask points selected the arrows move the points instead (#1288).
|
||||
fn nudge(app: &mut EffectcraftApp, ctx: &egui::Context, dir: &str) -> Result<Value, String> {
|
||||
@@ -131,7 +131,10 @@ fn nudge(app: &mut EffectcraftApp, ctx: &egui::Context, dir: &str) -> Result<Val
|
||||
_ => return Err(format!("unknown nudge `{dir}`")),
|
||||
};
|
||||
let st = &app.session.state;
|
||||
if app.ui.focused == PanelKind::Project || (st.selected_layers.is_empty() && st.selected_vertices.is_empty()) || st.text_edit.is_some() {
|
||||
if matches!(app.ui.focused, PanelKind::Project | PanelKind::EffectsPresets | PanelKind::RenderQueue)
|
||||
|| (st.selected_layers.is_empty() && st.selected_vertices.is_empty())
|
||||
|| st.text_edit.is_some()
|
||||
{
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
// Screen pixels per comp pixel.
|
||||
@@ -168,7 +171,7 @@ fn vertex_delta(app: &EffectcraftApp, layer: effectcraft_engine::project::LayerI
|
||||
})
|
||||
}
|
||||
|
||||
fn no_params(p: &Value) -> bool {
|
||||
pub(super) fn no_params(p: &Value) -> bool {
|
||||
p.as_object().is_none_or(|m| m.is_empty())
|
||||
}
|
||||
|
||||
@@ -223,6 +226,21 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Close follows panel focus too: with the Render Queue active, Cmd+W closes that panel and
|
||||
// leaves the active composition open (it used to close the composition next to it).
|
||||
if id == "file.close" && no_params(¶ms) && app.ui.focused == PanelKind::RenderQueue {
|
||||
app.apply_dock(craft_ui::docking::Action::Close { panel: PanelKind::RenderQueue })?;
|
||||
// The focus goes to a panel that is still there, so the next Close acts on the composition
|
||||
// instead of on the queue that is gone.
|
||||
let next = [PanelKind::Timeline, PanelKind::Composition, PanelKind::Project].into_iter().find(|p| panel_shown(app, *p)).unwrap_or(PanelKind::Project);
|
||||
app.show_panel(next);
|
||||
app.focus_panel(ctx, next);
|
||||
// Activating a passive viewer emits OpenComp, which raises Timeline. Finish that routing
|
||||
// before restoring the destination panel, including when they share the same tab group.
|
||||
app.handle_events(ctx);
|
||||
app.show_panel(next);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
// Edit ▸ Paste with SVG markup on the system clipboard pastes it as a shape layer (After
|
||||
// Effects 26.3). Copying in the app replaces the system clipboard with a note naming what
|
||||
// was copied, so SVG there was copied after anything copied in the app.
|
||||
|
||||
@@ -136,8 +136,8 @@ pub fn chord_matches(session: &effectcraft_engine::Session, command: &str, key:
|
||||
bindings(session).into_iter().any(|(m, k, id, _)| id == command && k == key && mods_match(m, mods))
|
||||
}
|
||||
|
||||
/// Whether a Deselect All chord matched by [`chord_matches`] may run while a text editor holds
|
||||
/// the keyboard. Chords carrying Cmd/Ctrl/Alt are command chords (the editor treats them as
|
||||
/// Whether a Deselect All or Duplicate chord matched by [`chord_matches`] may run while a text
|
||||
/// editor holds the keyboard. Chords carrying Cmd/Ctrl/Alt are command chords (the editor treats them as
|
||||
/// such for undo and the rest), and function keys never type, so those forward from the editor;
|
||||
/// a chord that could instead produce a character — a bare or Shift-only letter, digit or
|
||||
/// punctuation key, as from a pathological bare-letter rebinding — stays with the editor so
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -124,8 +124,10 @@ impl PresetRef {
|
||||
|
||||
/// Layers carried by a Project item drag. Dragging a selected item uses the
|
||||
/// complete selection, while dragging an unselected item uses only that item.
|
||||
/// Folders and data files are not composition layers.
|
||||
pub(crate) fn project_drop_items(app: &EffectcraftApp, dragged: u64) -> Vec<u64> {
|
||||
/// Folders and data files are not composition layers, and the composition the
|
||||
/// drop lands in is left out of a selection: it cannot contain itself, and one
|
||||
/// such item would make the engine refuse the whole drop (#2589).
|
||||
pub(crate) fn project_drop_items(app: &EffectcraftApp, dragged: u64, target: Option<effectcraft_engine::project::ItemId>) -> Vec<u64> {
|
||||
use effectcraft_engine::project::{FootageKind, ItemId, ItemKind};
|
||||
|
||||
let selected = &app.session.state.project_selection;
|
||||
@@ -139,6 +141,9 @@ pub(crate) fn project_drop_items(app: &EffectcraftApp, dragged: u64) -> Vec<u64>
|
||||
candidates
|
||||
.into_iter()
|
||||
.filter(|id| {
|
||||
if Some(ItemId(*id)) == target {
|
||||
return false;
|
||||
}
|
||||
app.session.project.item(ItemId(*id)).is_some_and(|item| match &item.kind {
|
||||
ItemKind::Comp(_) | ItemKind::Solid(_) => true,
|
||||
ItemKind::Footage(f) => f.kind != FootageKind::Data,
|
||||
|
||||
@@ -45,7 +45,7 @@ fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &st
|
||||
fn checkbox(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, text: &str, on: bool) -> bool {
|
||||
let t = app.tokens;
|
||||
let r = Rect::from_min_size(at, vec2(16.0, 16.0));
|
||||
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::unique(("animtool-cb", auto))).clicked();
|
||||
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::unique(("animtool-cb", auto))).changed();
|
||||
ui.painter().text(pos2(r.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text);
|
||||
app.auto.add(auto, r, text);
|
||||
if clicked { !on } else { on }
|
||||
|
||||
@@ -310,7 +310,7 @@ fn tab_body(
|
||||
let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.width).range(4..=30000).suffix(" px")));
|
||||
auto.add("dialog.comp.width", r.rect, "Width");
|
||||
let lbl = tr_args("Lock Aspect Ratio to {}", &[&(aspect_label(ow as f64, oh as f64))]);
|
||||
let r = ui.checkbox(&mut d.lock_aspect, lbl);
|
||||
let r = crate::widgets::labelled_checkbox(ui, &mut d.lock_aspect, lbl);
|
||||
auto.add("dialog.comp.lockAspect", r.rect, "Lock Aspect Ratio");
|
||||
if d.lock_aspect && ow != d.width && ow > 0 {
|
||||
d.height = ((d.width as f64) * oh as f64 / ow as f64).round().max(4.0) as u32;
|
||||
@@ -426,11 +426,11 @@ fn tab_body(
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("");
|
||||
let r = ui.checkbox(&mut d.preserve_frame_rate, tr("Preserve frame rate when nested or in render queue"));
|
||||
let r = crate::widgets::labelled_checkbox(ui, &mut d.preserve_frame_rate, tr("Preserve frame rate when nested or in render queue"));
|
||||
auto.add("dialog.comp.preserveFrameRate", r.rect, "Preserve frame rate when nested or in render queue");
|
||||
ui.end_row();
|
||||
ui.label("");
|
||||
let r = ui.checkbox(&mut d.preserve_resolution, tr("Preserve resolution when nested"));
|
||||
let r = crate::widgets::labelled_checkbox(ui, &mut d.preserve_resolution, tr("Preserve resolution when nested"));
|
||||
auto.add("dialog.comp.preserveResolution", r.rect, "Preserve resolution when nested");
|
||||
ui.end_row();
|
||||
ui.label(egui::RichText::new(tr("Motion Blur")).strong());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -231,12 +231,12 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label(tr("Depth of Field"));
|
||||
ui.checkbox(&mut d.dof, tr("Enable Depth of Field"));
|
||||
crate::widgets::labelled_checkbox(ui, &mut d.dof, tr("Enable Depth of Field"));
|
||||
ui.end_row();
|
||||
ui.label(tr("Focus Distance"));
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_enabled(!d.lock_to_zoom, crate::widgets::drag_value(egui::DragValue::new(&mut d.focus).range(0.0..=100000.0).suffix(" px")));
|
||||
ui.checkbox(&mut d.lock_to_zoom, tr("Lock to Zoom"));
|
||||
crate::widgets::labelled_checkbox(ui, &mut d.lock_to_zoom, tr("Lock to Zoom"));
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label(tr("Aperture"));
|
||||
@@ -354,7 +354,7 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
}
|
||||
if casts_shadows(d.kind) {
|
||||
ui.label(tr("Shadows"));
|
||||
let cb = ui.checkbox(&mut d.casts_shadows, tr("Casts Shadows"));
|
||||
let cb = crate::widgets::labelled_checkbox(ui, &mut d.casts_shadows, tr("Casts Shadows"));
|
||||
app.auto.add_response("dialog.light.casts", &cb, "Casts Shadows");
|
||||
ui.end_row();
|
||||
ui.label(tr("Shadow Darkness"));
|
||||
|
||||
@@ -93,8 +93,19 @@ impl PropCx<'_> {
|
||||
self.t.for_value(self.prop.has_expression())
|
||||
}
|
||||
|
||||
/// Queues the `prop.set` of one edit; consecutive edits of the parameter (a scrub) are one undo step.
|
||||
fn set(&self, actions: &mut Actions, v: serde_json::Value) {
|
||||
let (layer, uid, merge, scrub) = (self.layer, self.uid, format!("ec-{}", self.uid), self.scrub);
|
||||
self.push(actions, v, Some(format!("ec-{}", self.uid)));
|
||||
}
|
||||
|
||||
/// Queues the `prop.set` of a discrete choice (a pop-up pick, a check box): its own undo step,
|
||||
/// never merged with the parameter's previous edit (#2152).
|
||||
fn set_step(&self, actions: &mut Actions, v: serde_json::Value) {
|
||||
self.push(actions, v, None);
|
||||
}
|
||||
|
||||
fn push(&self, actions: &mut Actions, v: serde_json::Value, merge: Option<String>) {
|
||||
let (layer, uid, scrub) = (self.layer, self.uid, self.scrub);
|
||||
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub})))
|
||||
}
|
||||
|
||||
@@ -240,7 +251,7 @@ fn value_mask(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &
|
||||
}
|
||||
app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name);
|
||||
if let Some(ni) = widgets::popup_menu(ui, pop, dr.left_bottom(), &opts, Some(i)) {
|
||||
cx.set(actions, json!(ni as f64));
|
||||
cx.set_step(actions, json!(ni as f64));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,8 +378,8 @@ fn value_toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value:
|
||||
};
|
||||
let PropCx { t, uid, cy, vx, p, prop, .. } = *cx;
|
||||
let cr = Rect::from_min_size(pos2(vx, cy - 8.0), vec2(16.0, 16.0));
|
||||
if widgets::checkbox(ui, cr, *b, &t, egui::Id::unique(("ec-b", uid))).clicked() {
|
||||
cx.set(actions, json!(!b));
|
||||
if widgets::checkbox(ui, cr, *b, &t, egui::Id::unique(("ec-b", uid))).changed() {
|
||||
cx.set_step(actions, json!(!b));
|
||||
}
|
||||
p.text(pos2(cr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text_dim);
|
||||
app.auto.add(&format!("effectControls.prop.{uid}.value"), cr, &prop.name);
|
||||
@@ -388,7 +399,7 @@ fn value_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value:
|
||||
}
|
||||
app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name);
|
||||
if let Some(ni) = widgets::popup_menu(ui, pop, dr.left_bottom(), options, Some(*i as usize)) {
|
||||
cx.set(actions, json!(ni));
|
||||
cx.set_step(actions, json!(ni));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -402,7 +413,7 @@ fn value_layer_ref(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, val
|
||||
if let Some(comp) = comp {
|
||||
let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(((r.max.x - vx) * 0.55).clamp(90.0, 170.0), 18.0));
|
||||
if let Some(chosen) = widgets::layer_picker(ui, dr, &comp.layers, *l, &t, egui::Id::unique(("ec-l", uid))) {
|
||||
cx.set(actions, chosen.map(|id| json!(id)).unwrap_or(serde_json::Value::Null));
|
||||
cx.set_step(actions, chosen.map(|id| json!(id)).unwrap_or(serde_json::Value::Null));
|
||||
}
|
||||
app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name);
|
||||
// Source / Masks / Effects & Masks (the hidden companion parameter).
|
||||
|
||||
@@ -10,6 +10,7 @@ use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::a11y;
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::panels::{DragPayload, PresetRef};
|
||||
use crate::theme::Tokens;
|
||||
@@ -107,12 +108,70 @@ fn highlighted(p: &egui::Painter, pos: egui::Pos2, name: &str, query: &str, t: &
|
||||
p.text(pos, Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text);
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Apply {
|
||||
Effect(String),
|
||||
/// A text, built-in (Transitions…) or user file animation preset.
|
||||
Preset(PresetRef),
|
||||
}
|
||||
|
||||
/// One row of the list as drawn this pass, including the rows scrolled out of view: what the
|
||||
/// arrow keys, Home / End and Enter work on (the rows are painted by hand, so this is the list).
|
||||
struct NavRow {
|
||||
id: egui::Id,
|
||||
/// The favourite star's widget, for an effect row.
|
||||
star: Option<egui::Id>,
|
||||
parent: Option<egui::Id>,
|
||||
/// Where the row starts in the scrolled content.
|
||||
top: f32,
|
||||
/// A folder: (its key, open, forced open by a search).
|
||||
folder: Option<(String, bool, bool)>,
|
||||
/// What Enter applies, for an effect or preset row.
|
||||
apply: Option<Apply>,
|
||||
}
|
||||
|
||||
/// The list's keyboard state: the rows drawn, and the row the keys work from (the "cursor":
|
||||
/// selected, and the one Tab stop of the list).
|
||||
struct Nav {
|
||||
rows: Vec<NavRow>,
|
||||
/// The open folders on the way down to the row being drawn, by level.
|
||||
stack: Vec<egui::Id>,
|
||||
cursor: Option<egui::Id>,
|
||||
/// The scroll offset and the list's top, to place rows in the content.
|
||||
offset: f32,
|
||||
list_top: f32,
|
||||
}
|
||||
|
||||
impl Nav {
|
||||
/// The row `id` is the list's Tab stop: the cursor row, or the first row while there is none.
|
||||
fn tab_stop(&self, id: egui::Id) -> bool {
|
||||
match self.cursor {
|
||||
Some(c) => c == id,
|
||||
None => self.rows.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the row drawn at `y`; returns whether it is the cursor row.
|
||||
fn push(&mut self, id: egui::Id, y: f32, level: usize, folder: Option<(String, bool, bool)>, apply: Option<Apply>) -> bool {
|
||||
let parent = level.checked_sub(2).and_then(|i| self.stack.get(i)).copied();
|
||||
if folder.is_some() {
|
||||
self.stack.truncate(level.saturating_sub(1));
|
||||
self.stack.push(id);
|
||||
}
|
||||
self.rows.push(NavRow { id, star: None, parent, top: y - self.list_top + self.offset, folder, apply });
|
||||
self.cursor == Some(id)
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_id() -> egui::Id {
|
||||
egui::Id::unique("fx-cursor")
|
||||
}
|
||||
|
||||
/// The accessible name of a folder: what the row shows, in the interface language.
|
||||
fn folder_name(label: &str) -> &str {
|
||||
tr(label)
|
||||
}
|
||||
|
||||
/// A folder row; returns whether it is open.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn folder_row(
|
||||
@@ -121,8 +180,10 @@ fn folder_row(
|
||||
lp: &egui::Painter,
|
||||
list: Rect,
|
||||
y: &mut f32,
|
||||
nav: &mut Nav,
|
||||
key: &str,
|
||||
label: &str,
|
||||
level: usize,
|
||||
indent: f32,
|
||||
forced_open: bool,
|
||||
icon: Option<Icon>,
|
||||
@@ -130,12 +191,18 @@ fn folder_row(
|
||||
let t = app.tokens;
|
||||
let open = forced_open || app.ui.effects_open.contains(key);
|
||||
let r = Rect::from_min_size(pos2(list.min.x, *y), vec2(list.width(), ROW_H));
|
||||
let id = egui::Id::unique(("fxcat", key));
|
||||
let tab = nav.tab_stop(id);
|
||||
let selected = nav.push(id, *y, level, Some((key.to_string(), open, forced_open)), None);
|
||||
*y += ROW_H;
|
||||
if r.max.y < list.min.y || r.min.y > list.max.y {
|
||||
return open;
|
||||
}
|
||||
let resp = ui.interact(r.intersect(list), egui::Id::unique(("fxcat", key)), Sense::click());
|
||||
if resp.hovered() {
|
||||
let shown = folder_name(label);
|
||||
let resp = ui.interact(r.intersect(list), id, if tab { Sense::click() } else { Sense::CLICK });
|
||||
if selected {
|
||||
lp.rect_filled(r, 0.0, t.row_selected);
|
||||
} else if resp.hovered() {
|
||||
lp.rect_filled(r, 0.0, t.hover);
|
||||
}
|
||||
let tw = Rect::from_center_size(pos2(r.min.x + 12.0 + indent, r.center().y), vec2(12.0, 12.0));
|
||||
@@ -146,18 +213,44 @@ fn folder_row(
|
||||
Some(i) => icons::paint(lp, ic, i, t.text_dim),
|
||||
None => icons::paint(lp, ic, Icon::Folder, Color32::from_rgb(0xc8, 0xb0, 0x58)),
|
||||
}
|
||||
lp.text(pos2(r.min.x + 40.0 + indent, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text);
|
||||
lp.text(pos2(r.min.x + 40.0 + indent, r.center().y), Align2::LEFT_CENTER, shown, Tokens::ui(12.0), t.text);
|
||||
app.auto.add(&format!("effects.category.{key}"), r, label);
|
||||
if resp.clicked() && !forced_open {
|
||||
if open {
|
||||
app.ui.effects_open.remove(key);
|
||||
} else {
|
||||
app.ui.effects_open.insert(key.to_string());
|
||||
a11y::tree_item(&resp, shown, selected, level, Some(open));
|
||||
row_focus(app, ui, lp, &resp, r, id);
|
||||
if resp.clicked() {
|
||||
nav_click(ui.ctx(), app, id);
|
||||
if !forced_open {
|
||||
if open {
|
||||
app.ui.effects_open.remove(key);
|
||||
} else {
|
||||
app.ui.effects_open.insert(key.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
open
|
||||
}
|
||||
|
||||
/// What every row does with the keyboard focus: the focus ring, the panel taking the focus, the
|
||||
/// list owning its keys, and moving the focus here when the arrow keys asked for it.
|
||||
fn row_focus(app: &mut EffectcraftApp, ui: &egui::Ui, lp: &egui::Painter, resp: &egui::Response, r: Rect, id: egui::Id) {
|
||||
let ctx = ui.ctx();
|
||||
if a11y::take_pending_focus(ctx, "fx", id) {
|
||||
resp.request_focus();
|
||||
}
|
||||
if resp.gained_focus() {
|
||||
app.ui.focused = crate::dock::PanelKind::EffectsPresets;
|
||||
}
|
||||
if resp.has_focus() {
|
||||
lp.rect_stroke(r.shrink(0.5), 0.0, Stroke::new(1.0, app.tokens.accent), egui::StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
|
||||
/// A click on a row moves the cursor there and gives the panel the keyboard.
|
||||
fn nav_click(ctx: &egui::Context, app: &mut EffectcraftApp, id: egui::Id) {
|
||||
ctx.data_mut(|d| d.insert_temp(cursor_id(), id));
|
||||
app.ui.focused = crate::dock::PanelKind::EffectsPresets;
|
||||
}
|
||||
|
||||
/// Number of visible rows before Animation Presets in the current panel state.
|
||||
fn rows_before_presets(favorites: usize, recent: usize, searching: bool, favorites_open: bool, recent_open: bool) -> usize {
|
||||
let mut rows = 0usize;
|
||||
@@ -184,7 +277,9 @@ fn effect_row(
|
||||
lp: &egui::Painter,
|
||||
list: Rect,
|
||||
y: &mut f32,
|
||||
nav: &mut Nav,
|
||||
e: &'static effectcraft_engine::effects::EffectSpec,
|
||||
level: usize,
|
||||
indent: f32,
|
||||
query: &str,
|
||||
auto_prefix: &str,
|
||||
@@ -193,13 +288,22 @@ fn effect_row(
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
let r = Rect::from_min_size(pos2(list.min.x, *y), vec2(list.width(), ROW_H));
|
||||
let id = egui::Id::unique((auto_prefix, e.id));
|
||||
let star_id = egui::Id::unique((auto_prefix, e.id, "star"));
|
||||
let tab = nav.tab_stop(id);
|
||||
let selected = nav.push(id, *y, level, None, Some(Apply::Effect(e.id.to_string())));
|
||||
if let Some(row) = nav.rows.last_mut() {
|
||||
row.star = Some(star_id);
|
||||
}
|
||||
*y += ROW_H;
|
||||
if r.max.y < list.min.y || r.min.y > list.max.y {
|
||||
return;
|
||||
}
|
||||
let star = Rect::from_center_size(pos2(r.max.x - 38.0, r.center().y), vec2(16.0, 16.0));
|
||||
let resp = ui.interact(r.intersect(list), egui::Id::unique((auto_prefix, e.id)), Sense::click_and_drag());
|
||||
if resp.hovered() {
|
||||
let resp = ui.interact(r.intersect(list), id, if tab { Sense::click_and_drag() } else { Sense::CLICK | Sense::DRAG });
|
||||
if selected {
|
||||
lp.rect_filled(r, 0.0, t.row_selected);
|
||||
} else if resp.hovered() {
|
||||
lp.rect_filled(r, 0.0, t.hover);
|
||||
}
|
||||
icons::paint(lp, Rect::from_center_size(pos2(r.min.x + 44.0 + indent, r.center().y), vec2(12.0, 12.0)), Icon::Fx, t.text_dim);
|
||||
@@ -211,20 +315,32 @@ fn effect_row(
|
||||
lp.text(pos2(r.max.x - 10.0, r.center().y), Align2::RIGHT_CENTER, "32", Tokens::ui(9.5), t.text_faint);
|
||||
}
|
||||
app.auto.add(&format!("{auto_prefix}.{}", e.id), r, e.name);
|
||||
// Favourite star (always shown when starred, else on hover).
|
||||
a11y::tree_item(&resp, &shown, selected, level, None);
|
||||
row_focus(app, ui, lp, &resp, r, id);
|
||||
// Favourite star (always shown when starred, else on hover or with the keyboard focus).
|
||||
let fav = app.session.prefs.is_effect_favorite(e.id);
|
||||
let sresp = ui.interact(star.intersect(list), egui::Id::unique((auto_prefix, e.id, "star")), Sense::click());
|
||||
if fav || resp.hovered() || sresp.hovered() {
|
||||
let sresp = ui.interact(star.intersect(list), star_id, if tab { Sense::click() } else { Sense::CLICK });
|
||||
if fav || resp.hovered() || sresp.hovered() || sresp.has_focus() {
|
||||
let gold = Color32::from_rgb(0xe8, 0xc0, 0x40);
|
||||
filled_star(lp, star.center(), 6.0, fav.then_some(gold), if fav || sresp.hovered() { gold } else { t.text_faint });
|
||||
}
|
||||
app.auto.add(&format!("{auto_prefix}.{}.star", e.id), star, if fav { "Remove from Favorites" } else { "Add to Favorites" });
|
||||
a11y::button(&sresp, &format!("{}: {shown}", tr(if fav { "Remove from favorites" } else { "Add to favorites" })), true);
|
||||
if sresp.gained_focus() {
|
||||
app.ui.focused = crate::dock::PanelKind::EffectsPresets;
|
||||
}
|
||||
if sresp.has_focus() {
|
||||
widgets::focus_outline(ui, &sresp, &t);
|
||||
}
|
||||
if sresp.clicked() {
|
||||
// Kept in the settings, so the stars are there next time (#1692).
|
||||
app.session.prefs.toggle_effect_favorite(e.id);
|
||||
app.session.save_prefs();
|
||||
return;
|
||||
}
|
||||
if resp.clicked() {
|
||||
nav_click(&ctx, app, id);
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
*apply = Some(Apply::Effect(e.id.to_string()));
|
||||
}
|
||||
@@ -241,6 +357,7 @@ fn preset_row(
|
||||
lp: &egui::Painter,
|
||||
list: Rect,
|
||||
y: &mut f32,
|
||||
nav: &mut Nav,
|
||||
name: &str,
|
||||
auto_id: &str,
|
||||
query: &str,
|
||||
@@ -249,17 +366,27 @@ fn preset_row(
|
||||
) {
|
||||
let t = app.tokens;
|
||||
let r = Rect::from_min_size(pos2(list.min.x, *y), vec2(list.width(), ROW_H));
|
||||
let id = egui::Id::unique(("fxpreset", auto_id));
|
||||
let tab = nav.tab_stop(id);
|
||||
let selected = nav.push(id, *y, 3, None, Some(Apply::Preset(preset.clone())));
|
||||
*y += ROW_H;
|
||||
if r.max.y < list.min.y || r.min.y > list.max.y {
|
||||
return;
|
||||
}
|
||||
let resp = ui.interact(r.intersect(list), egui::Id::unique(("fxpreset", auto_id)), Sense::click_and_drag());
|
||||
if resp.hovered() {
|
||||
let resp = ui.interact(r.intersect(list), id, if tab { Sense::click_and_drag() } else { Sense::CLICK | Sense::DRAG });
|
||||
if selected {
|
||||
lp.rect_filled(r, 0.0, t.row_selected);
|
||||
} else if resp.hovered() {
|
||||
lp.rect_filled(r, 0.0, t.hover);
|
||||
}
|
||||
icons::paint(lp, Rect::from_center_size(pos2(r.min.x + 58.0, r.center().y), vec2(12.0, 12.0)), Icon::Keyframe, t.text_dim);
|
||||
highlighted(lp, pos2(r.min.x + 70.0, r.center().y), name, query, &t);
|
||||
app.auto.add(auto_id, r, name);
|
||||
a11y::tree_item(&resp, name, selected, 3, None);
|
||||
row_focus(app, ui, lp, &resp, r, id);
|
||||
if resp.clicked() {
|
||||
nav_click(ui.ctx(), app, id);
|
||||
}
|
||||
if resp.drag_started() {
|
||||
egui::DragAndDrop::set_payload(ui.ctx(), DragPayload::Preset { preset: preset.clone(), name: name.to_string() });
|
||||
}
|
||||
@@ -268,6 +395,120 @@ fn preset_row(
|
||||
}
|
||||
}
|
||||
|
||||
/// The list's keys. With a row focused (Tab, a screen reader): Up / Down move between rows,
|
||||
/// Home / End go to the first / last, Page Up / Page Down a page, Right opens a folder (then steps
|
||||
/// into it) and Left closes it (then steps out to it), Enter and Space apply the row or open /
|
||||
/// close the folder. With the mouse's last click in the panel and no widget focused, the arrows
|
||||
/// and Enter work the same, so the list is operable without a mouse either way. The focus follows
|
||||
/// the cursor, so a screen reader reads each row as it is reached. Returns what to apply.
|
||||
fn list_keys(app: &mut EffectcraftApp, ctx: &egui::Context, nav: &Nav, list: Rect) -> Option<Apply> {
|
||||
use egui::Key;
|
||||
// A cursor whose row is gone (a search hid it): the first row is the Tab stop again.
|
||||
if let Some(c) = nav.cursor
|
||||
&& !nav.rows.iter().any(|r| r.id == c)
|
||||
{
|
||||
ctx.data_mut(|d| d.remove::<egui::Id>(cursor_id()));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
let focused = ctx.memory(|m| m.focused());
|
||||
let on_row = focused.is_some_and(|f| nav.rows.iter().any(|r| r.id == f || r.star == Some(f)));
|
||||
let by_mouse = focused.is_none() && app.ui.focused == crate::dock::PanelKind::EffectsPresets;
|
||||
if (!on_row && !by_mouse) || app.dialog.is_some() || widgets::any_menu_open(ctx) {
|
||||
return None;
|
||||
}
|
||||
let keys: Vec<Key> = ctx.input(|i| {
|
||||
i.events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
egui::Event::Key { key, pressed: true, modifiers, .. } if !modifiers.any() => Some(*key),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let page = ((list.height() / ROW_H).floor() as usize).saturating_sub(1).max(1);
|
||||
// The row with the keyboard focus is where the keys start (the first Tab lands on a row
|
||||
// before anything is selected); otherwise the cursor.
|
||||
let focus_row = focused.and_then(|f| nav.rows.iter().find(|r| r.id == f || r.star == Some(f))).map(|r| r.id);
|
||||
let mut cursor = focus_row.or_else(|| nav.cursor.filter(|c| nav.rows.iter().any(|r| r.id == *c)));
|
||||
let mut apply = None;
|
||||
let mut moved = false;
|
||||
for key in keys {
|
||||
let at = cursor.and_then(|c| nav.rows.iter().position(|r| r.id == c));
|
||||
let last = nav.rows.len().checked_sub(1)?;
|
||||
let to = match key {
|
||||
Key::ArrowDown => Some(at.map_or(0, |a| a.saturating_add(1).min(last))),
|
||||
Key::ArrowUp => Some(at.map_or(last, |a| a.saturating_sub(1))),
|
||||
Key::Home if on_row => Some(0),
|
||||
Key::End if on_row => Some(last),
|
||||
Key::PageDown if on_row => Some(at.map_or(0, |a| a.saturating_add(page).min(last))),
|
||||
Key::PageUp if on_row => Some(at.map_or(last, |a| a.saturating_sub(page))),
|
||||
Key::ArrowRight | Key::ArrowLeft | Key::Enter | Key::Space if at.is_some() => {
|
||||
let row = at.and_then(|a| nav.rows.get(a))?;
|
||||
let toggle = |app: &mut EffectcraftApp, open: bool| {
|
||||
if let Some((k, _, forced)) = &row.folder
|
||||
&& !forced
|
||||
{
|
||||
if open {
|
||||
app.ui.effects_open.insert(k.clone());
|
||||
} else {
|
||||
app.ui.effects_open.remove(k);
|
||||
}
|
||||
}
|
||||
};
|
||||
match (key, &row.folder) {
|
||||
(Key::ArrowRight, Some((_, false, _))) => {
|
||||
toggle(app, true);
|
||||
None
|
||||
}
|
||||
(Key::ArrowRight, Some((_, true, _))) => at.map(|a| a.saturating_add(1).min(last)),
|
||||
(Key::ArrowLeft, Some((_, true, false))) => {
|
||||
toggle(app, false);
|
||||
None
|
||||
}
|
||||
(Key::ArrowLeft, _) => row.parent.and_then(|p| nav.rows.iter().position(|r| r.id == p)),
|
||||
(Key::Enter, Some((_, open, _))) | (Key::Space, Some((_, open, _))) if on_row || key == Key::Enter => {
|
||||
toggle(app, !*open);
|
||||
None
|
||||
}
|
||||
(Key::Enter, None) => {
|
||||
apply = row.apply.clone();
|
||||
None
|
||||
}
|
||||
(Key::Space, None) if on_row => {
|
||||
apply = row.apply.clone();
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(to) = to
|
||||
&& let Some(row) = nav.rows.get(to)
|
||||
{
|
||||
cursor = Some(row.id);
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
if moved && let Some(c) = cursor {
|
||||
ctx.data_mut(|d| d.insert_temp(cursor_id(), c));
|
||||
// Scroll the row into view.
|
||||
if let Some(row) = nav.rows.iter().find(|r| r.id == c) {
|
||||
widgets::PanelScroll::reveal(ctx, egui::Id::unique("fx-scroll"), row.top, row.top + ROW_H, list.height());
|
||||
}
|
||||
app.ui.focused = crate::dock::PanelKind::EffectsPresets;
|
||||
if on_row {
|
||||
a11y::focus_next_pass(ctx, "fx", c);
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
// The arrows are the list's, not egui's spatial focus moves.
|
||||
if on_row {
|
||||
ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None));
|
||||
}
|
||||
apply
|
||||
}
|
||||
|
||||
/// User preset files, rescanned every few seconds (and on Refresh List).
|
||||
fn user_presets(ctx: &egui::Context, force: bool, scan: impl FnOnce() -> Vec<PresetFile>) -> Arc<Vec<PresetFile>> {
|
||||
let id = egui::Id::unique("fx-user-presets");
|
||||
@@ -289,7 +530,9 @@ fn contents_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, ctx: &egui::Contex
|
||||
// Contents menu.
|
||||
let mr = Rect::from_center_size(pos2(rect.max.x - 18.0, sr.center().y), vec2(22.0, 22.0));
|
||||
let mpop = egui::Id::unique("fx-contents-menu");
|
||||
if widgets::icon_button(ui, mr, Icon::Hamburger, false, t, egui::Id::unique("fx-menu")).on_hover_text(tr("Effects & Presets options")).clicked() {
|
||||
let menu_resp = widgets::icon_button(ui, mr, Icon::Hamburger, false, t, egui::Id::unique("fx-menu")).on_hover_text(tr("Effects & Presets options"));
|
||||
a11y::menu_button(&menu_resp, tr("Effects & Presets options"), widgets::popup_is_open(ui, mpop));
|
||||
if menu_resp.clicked() {
|
||||
widgets::open_popup(ui, mpop);
|
||||
}
|
||||
app.auto.add("effects.menu", mr, "Effects & Presets options");
|
||||
@@ -322,6 +565,12 @@ fn contents_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, ctx: &egui::Contex
|
||||
ui.separator();
|
||||
} else {
|
||||
let resp = ui.selectable_label(false, label);
|
||||
// The ✓ and the padding are the menu's drawing: say it as a state.
|
||||
let checkable = matches!(i, 0 | 1 | 2 | 4 | 6 | 7);
|
||||
a11y::menu_item(&resp, label.trim_start_matches('✓').trim(), checkable.then(|| label.starts_with('✓')));
|
||||
if i == 0 {
|
||||
widgets::focus_when_opened(ui, mpop, &resp);
|
||||
}
|
||||
if label == ©_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<Apply> = None;
|
||||
let mut nav = Nav { rows: vec![], stack: vec![], cursor: ctx.data(|d| d.get_temp::<egui::Id>(cursor_id())), offset: scroll.offset, list_top: list.min.y };
|
||||
let reg = effectcraft_engine::effects::all();
|
||||
let keep = |e: &effectcraft_engine::effects::EffectSpec| {
|
||||
depth_keeps(&view.depth, e)
|
||||
@@ -406,17 +661,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
rows_before_presets(favs.len(), recent.len(), searching, app.ui.effects_open.contains("favorites"), app.ui.effects_open.contains("recent"));
|
||||
scroll.offset = ROW_H * preceding_rows as f32;
|
||||
y = list.min.y - scroll.offset;
|
||||
nav.offset = scroll.offset;
|
||||
}
|
||||
if !favs.is_empty() && folder_row(app, ui, &lp, list, &mut y, "favorites", "Favorites", 0.0, searching, Some(Icon::Star)) {
|
||||
if !favs.is_empty() && folder_row(app, ui, &lp, list, &mut y, &mut nav, "favorites", "Favorites", 1, 0.0, searching, Some(Icon::Star)) {
|
||||
let mut favs = favs;
|
||||
favs.sort_by_key(|e| e.name);
|
||||
for e in favs {
|
||||
effect_row(app, ui, &lp, list, &mut y, e, 0.0, &query, "effects.favorites", &mut apply);
|
||||
effect_row(app, ui, &lp, list, &mut y, &mut nav, e, 2, 0.0, &query, "effects.favorites", &mut apply);
|
||||
}
|
||||
}
|
||||
if !recent.is_empty() && !searching && folder_row(app, ui, &lp, list, &mut y, "recent", "Recently Used", 0.0, false, None) {
|
||||
if !recent.is_empty() && !searching && folder_row(app, ui, &lp, list, &mut y, &mut nav, "recent", "Recently Used", 1, 0.0, false, None) {
|
||||
for e in recent {
|
||||
effect_row(app, ui, &lp, list, &mut y, e, 0.0, &query, "effects.recent", &mut apply);
|
||||
effect_row(app, ui, &lp, list, &mut y, &mut nav, e, 2, 0.0, &query, "effects.recent", &mut apply);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,21 +691,45 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let user = user_presets(&ctx, force_scan, || presets_dir().map(|d| scan_presets(&d)).unwrap_or_default());
|
||||
let files: Vec<&PresetFile> = user.iter().filter(|f| query.is_empty() || f.name.to_lowercase().contains(&query)).collect();
|
||||
let any = !text.is_empty() || !builtin.is_empty() || !files.is_empty();
|
||||
if any && folder_row(app, ui, &lp, list, &mut y, "presets", "* Animation Presets", 0.0, searching, None) {
|
||||
if !text.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/Text", "Text", 14.0, searching, None) {
|
||||
if any && folder_row(app, ui, &lp, list, &mut y, &mut nav, "presets", "* Animation Presets", 1, 0.0, searching, None) {
|
||||
if !text.is_empty() && folder_row(app, ui, &lp, list, &mut y, &mut nav, "presets/Text", "Text", 2, 14.0, searching, None) {
|
||||
for (id, name) in text {
|
||||
preset_row(app, ui, &lp, list, &mut y, &name, &format!("effects.preset.text.{id}"), &query, PresetRef::Text(id.clone()), &mut apply);
|
||||
preset_row(
|
||||
app,
|
||||
ui,
|
||||
&lp,
|
||||
list,
|
||||
&mut y,
|
||||
&mut nav,
|
||||
&name,
|
||||
&format!("effects.preset.text.{id}"),
|
||||
&query,
|
||||
PresetRef::Text(id.clone()),
|
||||
&mut apply,
|
||||
);
|
||||
}
|
||||
}
|
||||
for cat in &categories {
|
||||
if !folder_row(app, ui, &lp, list, &mut y, &format!("presets/{cat}"), cat, 14.0, searching, None) {
|
||||
if !folder_row(app, ui, &lp, list, &mut y, &mut nav, &format!("presets/{cat}"), cat, 2, 14.0, searching, None) {
|
||||
continue;
|
||||
}
|
||||
for (id, name, _) in builtin.iter().filter(|(_, _, c)| c == cat) {
|
||||
preset_row(app, ui, &lp, list, &mut y, name, &format!("effects.preset.builtin.{id}"), &query, PresetRef::Builtin(id.clone()), &mut apply);
|
||||
preset_row(
|
||||
app,
|
||||
ui,
|
||||
&lp,
|
||||
list,
|
||||
&mut y,
|
||||
&mut nav,
|
||||
name,
|
||||
&format!("effects.preset.builtin.{id}"),
|
||||
&query,
|
||||
PresetRef::Builtin(id.clone()),
|
||||
&mut apply,
|
||||
);
|
||||
}
|
||||
}
|
||||
if !files.is_empty() && folder_row(app, ui, &lp, list, &mut y, "presets/User", "User Presets", 14.0, searching, None) {
|
||||
if !files.is_empty() && folder_row(app, ui, &lp, list, &mut y, &mut nav, "presets/User", "User Presets", 2, 14.0, searching, None) {
|
||||
for f in files {
|
||||
preset_row(
|
||||
app,
|
||||
@@ -457,6 +737,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
&lp,
|
||||
list,
|
||||
&mut y,
|
||||
&mut nav,
|
||||
&f.name,
|
||||
&format!("effects.preset.file.{}", f.name),
|
||||
&query,
|
||||
@@ -473,7 +754,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let mut all: Vec<_> = reg.iter().filter(|e| keep(e)).collect();
|
||||
all.sort_by_key(|e| e.name.to_lowercase());
|
||||
for e in all {
|
||||
effect_row(app, ui, &lp, list, &mut y, e, -16.0, &query, "effects.item", &mut apply);
|
||||
effect_row(app, ui, &lp, list, &mut y, &mut nav, e, 1, -16.0, &query, "effects.item", &mut apply);
|
||||
}
|
||||
} else {
|
||||
let mut cats: Vec<&str> = effectcraft_engine::effects::categories();
|
||||
@@ -485,11 +766,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
// The folder draws the category in the interface language; `cat` stays the key (the
|
||||
// open/closed state and the automation id are the registry's English name).
|
||||
if !folder_row(app, ui, &lp, list, &mut y, cat, &crate::i18n::effects::category(cat), 0.0, searching, None) {
|
||||
if !folder_row(app, ui, &lp, list, &mut y, &mut nav, cat, &crate::i18n::effects::category(cat), 1, 0.0, searching, None) {
|
||||
continue;
|
||||
}
|
||||
for e in items {
|
||||
effect_row(app, ui, &lp, list, &mut y, e, 0.0, &query, "effects.item", &mut apply);
|
||||
effect_row(app, ui, &lp, list, &mut y, &mut nav, e, 2, 0.0, &query, "effects.item", &mut apply);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -498,6 +779,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
let content = y + scroll.offset - list.min.y;
|
||||
scroll.end(ui, &mut app.auto, "effects.scroll", content, &t);
|
||||
if let Some(a) = list_keys(app, &ctx, &nav, list) {
|
||||
apply = Some(a);
|
||||
}
|
||||
if let Some(name) = egui::DragAndDrop::payload::<DragPayload>(&ctx).and_then(|p| match p.as_ref() {
|
||||
DragPayload::Effect(id) => Some(effectcraft_engine::effects::find(id).map(|e| crate::i18n::effects::name(e.id, e.name)).unwrap_or_default()),
|
||||
DragPayload::Preset { name, .. } => Some(name.clone()),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -760,7 +760,16 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
|
||||
)
|
||||
}
|
||||
// File ▸ Export ▸ Adobe Premiere Pro Project…: Final Cut Pro XML (or FCPXML / OTIO / EDL).
|
||||
"file.exportTimeline" if !has(p, &["path"]) => export_timeline_form(s),
|
||||
"file.exportTimeline" if !has(p, &["path"]) => {
|
||||
// Its pre-renders run as a job in Window ▸ Progress, not with the window waiting (#2269).
|
||||
match base.as_object_mut() {
|
||||
Some(m) => {
|
||||
m.insert("background".into(), json!(true));
|
||||
}
|
||||
None => base = json!({"background": true}),
|
||||
}
|
||||
export_timeline_form(s)
|
||||
}
|
||||
"file.importPlaceholder" | "file.replaceWithPlaceholder" if p.as_object().is_none_or(|m| m.is_empty()) => (
|
||||
"New Placeholder".into(),
|
||||
vec![
|
||||
@@ -888,7 +897,7 @@ fn form_grid(ui: &mut egui::Ui, f: &mut Form, regs: &mut Vec<(String, egui::Rect
|
||||
.inner
|
||||
.rect
|
||||
}
|
||||
FieldKind::Bool(b) => ui.checkbox(b, "").rect,
|
||||
FieldKind::Bool(b) => crate::widgets::labelled_checkbox(ui, b, "").rect,
|
||||
FieldKind::Choice { options, sel } => {
|
||||
let cur = options.get(*sel).map(|o| o.0.clone()).unwrap_or_default();
|
||||
// The column keeps its width: a long choice (a GPU adapter's name) ends in an
|
||||
@@ -1018,13 +1027,13 @@ pub fn show_view_options(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Toke
|
||||
let mut close = false;
|
||||
super::dialogs::modal(ctx, "View Options", vec2(360.0, 330.0), t, |ui| {
|
||||
let v = &mut app.ui.viewer;
|
||||
ui.checkbox(&mut v.show_layer_controls, tr("Layer controls"));
|
||||
ui.checkbox(&mut v.show_masks, tr("Masks"));
|
||||
ui.checkbox(&mut v.rulers, tr("Rulers"));
|
||||
ui.checkbox(&mut v.guides, tr("Guides"));
|
||||
ui.checkbox(&mut v.grid, tr("Grid"));
|
||||
ui.checkbox(&mut v.safe_margins, tr("Title/action safe"));
|
||||
ui.checkbox(&mut v.transparency_grid, tr("Transparency grid"));
|
||||
crate::widgets::labelled_checkbox(ui, &mut v.show_layer_controls, tr("Layer controls"));
|
||||
crate::widgets::labelled_checkbox(ui, &mut v.show_masks, tr("Masks"));
|
||||
crate::widgets::labelled_checkbox(ui, &mut v.rulers, tr("Rulers"));
|
||||
crate::widgets::labelled_checkbox(ui, &mut v.guides, tr("Guides"));
|
||||
crate::widgets::labelled_checkbox(ui, &mut v.grid, tr("Grid"));
|
||||
crate::widgets::labelled_checkbox(ui, &mut v.safe_margins, tr("Title/action safe"));
|
||||
crate::widgets::labelled_checkbox(ui, &mut v.transparency_grid, tr("Transparency grid"));
|
||||
ui.add_space(12.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.add(egui::Button::new(egui::RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent)).clicked() {
|
||||
@@ -1117,8 +1126,11 @@ mod tests {
|
||||
let params = app.dialog_state.form.params();
|
||||
assert!(params.get("note").is_none());
|
||||
assert_eq!(params["format"], "xml");
|
||||
let r = app.session.execute("file.exportTimeline", params).unwrap();
|
||||
assert_eq!(r["path"], path.as_str());
|
||||
assert_eq!(params["background"], true, "the dialog exports in the background");
|
||||
assert!(app.session.execute("file.exportTimeline", params).unwrap()["job"].is_string());
|
||||
app.session.wait_jobs();
|
||||
let done = app.session.job_log.last().unwrap();
|
||||
assert_eq!((done.status.as_str(), &done.result["path"]), ("done", &json!(path)));
|
||||
assert!(std::fs::read_to_string(&path).unwrap().contains("<xmeml"));
|
||||
}
|
||||
|
||||
|
||||
@@ -245,7 +245,7 @@ pub fn open(app: &mut EffectcraftApp, p: &Json) -> Result<Json, String> {
|
||||
/// Set the property to the draft: one `prop.set` at the time the editor opened, in composition
|
||||
/// time, or nothing when the draft is unchanged (so OK adds no keyframe).
|
||||
fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
app.dialog = None;
|
||||
close(app);
|
||||
let d = std::mem::take(&mut app.dialog_state.gradient);
|
||||
let g = d.gradient();
|
||||
// The preview already set the property to this draft, in one merged undo step: OK keeps it.
|
||||
@@ -279,7 +279,12 @@ pub fn finish(app: &mut EffectcraftApp, ok: bool, ctx: &egui::Context) {
|
||||
preview_merge::rollback(app, cp, d.applied_at.as_ref());
|
||||
}
|
||||
app.session.history.merge_key = None;
|
||||
app.dialog = None;
|
||||
close(app);
|
||||
}
|
||||
|
||||
/// Close the editor: back to the Layer Style dialog when it opened from there (#2057).
|
||||
fn close(app: &mut EffectcraftApp) {
|
||||
app.dialog = std::mem::take(&mut app.dialog_state.layer_style.gradient_open).then_some(Dialog::LayerStyles);
|
||||
}
|
||||
|
||||
/// Keep the property in step with the draft while Preview is on: apply a changed draft with one
|
||||
|
||||
@@ -470,6 +470,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
let mut key_screens: Vec<(serde_json::Value, Pos2)> = vec![];
|
||||
for c in &cs {
|
||||
let (lid, uid, d) = (c.layer.id, c.prop.uid, c.dim);
|
||||
let unlocked = !c.layer.switches.locked;
|
||||
for k in &c.keys {
|
||||
let kp = pos2(tm.x(k.t), ymap(k.v));
|
||||
// In the speed graph an end key rises with the handle being dragged; skipping it once it
|
||||
@@ -491,9 +492,10 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
app.auto.add(&format!("timeline.graph.handle.{uid}.{d}.{}.{side}", k.idx), hr, "Bezier handle");
|
||||
// The same side's handles of every selected key move with it, by as much as the
|
||||
// pointer moved (After Effects), in one undo step.
|
||||
if hresp.drag_started() {
|
||||
if unlocked && hresp.drag_started() {
|
||||
let grabs: Vec<HandleGrab> = cs
|
||||
.iter()
|
||||
.filter(|c2| !c2.layer.switches.locked)
|
||||
.flat_map(|c2| {
|
||||
c2.keys.iter().flat_map(move |k2| {
|
||||
k2.handles.iter().filter(|h2| h2.out == h.out).map(move |h2| HandleGrab {
|
||||
@@ -513,7 +515,8 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
let press = ui.input(|i| i.pointer.press_origin()).unwrap_or(hp);
|
||||
ctx.data_mut(|dd| dd.insert_temp::<HandleDrag>(handle_drag_id(), (press, grabs)));
|
||||
}
|
||||
if hresp.dragged()
|
||||
if unlocked
|
||||
&& hresp.dragged()
|
||||
&& let Some(pt) = hresp.interact_pointer_pos()
|
||||
&& let Some((press, grabs)) = ctx.data(|dd| dd.get_temp::<HandleDrag>(handle_drag_id()))
|
||||
{
|
||||
@@ -544,7 +547,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
app.auto.add(&format!("timeline.graph.key.{uid}.{d}.{}", k.idx), kr, &c.prop.name);
|
||||
let mods = ui.input(|i| i.modifiers);
|
||||
let key = &c.prop.keys[k.idx];
|
||||
if kresp.clicked() && mods.command {
|
||||
if unlocked && kresp.clicked() && mods.command {
|
||||
// Ctrl+click: Linear ↔ Auto Bezier; Ctrl+Alt+click: Hold on / off (as in the Timeline).
|
||||
actions.push(("keys.select".into(), json!({"keys": [kjson.clone()]})));
|
||||
if mods.alt {
|
||||
@@ -553,20 +556,22 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
let linear = key.in_interp == Interp::Linear && key.out_interp == Interp::Linear;
|
||||
actions.push(("keys.interpolation".into(), json!({"interpolation": if linear { "autoBezier" } else { "linear" }})));
|
||||
}
|
||||
} else if kresp.clicked() && mods.shift {
|
||||
} else if unlocked && kresp.clicked() && mods.shift {
|
||||
actions.push(("keys.select".into(), json!({"keys": [kjson.clone()], "toggle": true})));
|
||||
} else if (kresp.clicked() || kresp.drag_started()) && !k.sel {
|
||||
} else if unlocked && (kresp.clicked() || kresp.drag_started() || kresp.secondary_clicked()) && !k.sel {
|
||||
actions.push(("keys.select".into(), json!({"keys": [kjson.clone()], "add": mods.shift})));
|
||||
}
|
||||
// A drag moves every selected key, by how far the pointer went from where the button
|
||||
// went down (the key doesn't jump under the pointer); Shift keeps it to the time or
|
||||
// the value axis, whichever moved more.
|
||||
if kresp.drag_started()
|
||||
if unlocked
|
||||
&& kresp.drag_started()
|
||||
&& let Some(o) = ui.input(|i| i.pointer.press_origin())
|
||||
{
|
||||
ctx.data_mut(|dd| dd.insert_temp(key_grab_id(), KeyGrab { press: o, t: k.t }));
|
||||
}
|
||||
if kresp.dragged()
|
||||
if unlocked
|
||||
&& kresp.dragged()
|
||||
&& let Some(pt) = kresp.interact_pointer_pos()
|
||||
&& let Some(g) = ctx.data(|dd| dd.get_temp::<KeyGrab>(key_grab_id()))
|
||||
{
|
||||
@@ -587,6 +592,8 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
||||
ctx.data_mut(|dd| dd.insert_temp(drag_id, false));
|
||||
actions.push(("__endMerge".into(), json!({})));
|
||||
}
|
||||
// Right-click: the Timeline's key menu, with the same commands.
|
||||
crate::widgets::context_menu(&kresp, |ui| super::timeline::key_menu(ui, app, c.layer, c.prop, key, k.sel, actions));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -168,7 +168,7 @@ pub fn velocity(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
let r = ui.checkbox(&mut d.continuous, tr("Continuous (Lock Outgoing to Incoming)"));
|
||||
let r = crate::widgets::labelled_checkbox(ui, &mut d.continuous, tr("Continuous (Lock Outgoing to Incoming)"));
|
||||
app.auto.add("dialog.velocity.continuous", r.rect, "Continuous");
|
||||
if d.continuous {
|
||||
d.out_speed = d.in_speed.clone();
|
||||
|
||||
@@ -23,6 +23,8 @@ pub struct LayerStyleState {
|
||||
pub selected: String,
|
||||
pub undo_mark: usize,
|
||||
checkpoint: Option<Checkpoint>,
|
||||
/// The Gradient Editor opened from this dialog: closing it comes back here (#2057).
|
||||
pub gradient_open: bool,
|
||||
}
|
||||
|
||||
/// The project and undo history as a dialog found them, for a Cancel that rolls its live edits back.
|
||||
@@ -58,7 +60,8 @@ pub fn open(app: &mut EffectcraftApp, params: &Value) -> Result<(), String> {
|
||||
return Err(format!("unknown layer style `{style}`"));
|
||||
}
|
||||
let checkpoint = checkpoint(app);
|
||||
app.dialog_state.layer_style = LayerStyleState { layer, selected: style, undo_mark: app.session.history.undo.len(), checkpoint: Some(checkpoint) };
|
||||
app.dialog_state.layer_style =
|
||||
LayerStyleState { layer, selected: style, undo_mark: app.session.history.undo.len(), checkpoint: Some(checkpoint), gradient_open: false };
|
||||
app.dialog = Some(Dialog::LayerStyles);
|
||||
Ok(())
|
||||
}
|
||||
@@ -89,7 +92,7 @@ fn control(ui: &mut egui::Ui, p: &Property, v: &KV) -> Option<Value> {
|
||||
match (v, &p.ui) {
|
||||
(KV::Bool(b), _) => {
|
||||
let mut on = *b;
|
||||
ui.checkbox(&mut on, "").changed().then(|| json!(on))
|
||||
crate::widgets::labelled_checkbox(ui, &mut on, "").changed().then(|| json!(on))
|
||||
}
|
||||
(KV::Enum(i), ParamUi::Popup { options }) => {
|
||||
let mut sel = *i as usize;
|
||||
@@ -118,10 +121,6 @@ fn control(ui: &mut egui::Ui, p: &Property, v: &KV) -> Option<Value> {
|
||||
let changed = crate::widgets::srgb_color_button(ui, &mut rgb).changed();
|
||||
changed.then(|| json!(rgb))
|
||||
}
|
||||
(KV::Gradient(_), _) => {
|
||||
ui.label(egui::RichText::new(tr("Edit in the Timeline")).weak());
|
||||
None
|
||||
}
|
||||
_ => {
|
||||
ui.label(egui::RichText::new(format!("{v:?}").chars().take(24).collect::<String>()).weak());
|
||||
None
|
||||
@@ -177,7 +176,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
if id != BLENDING {
|
||||
let g = styles.as_ref().and_then(|s| s.sub(id));
|
||||
let mut on = g.is_some_and(|g| g.enabled);
|
||||
if ui.checkbox(&mut on, "").changed() {
|
||||
if crate::widgets::labelled_checkbox(ui, &mut on, "").changed() {
|
||||
if g.is_none() {
|
||||
actions.push(("layer.style.add".into(), json!({"layers": [lid], "style": id})));
|
||||
} else {
|
||||
@@ -228,6 +227,19 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
}
|
||||
let lbl = ui.label(&p.name);
|
||||
let v = p.value_at(lt);
|
||||
if matches!(v, KV::Gradient(_)) {
|
||||
// The Gradient Editor, over this dialog (a style the dialog
|
||||
// only shows has no property to edit yet).
|
||||
let path = g.match_path_of(p.uid).unwrap_or_default();
|
||||
let r = ui.add_enabled(missing.is_none(), egui::Button::new(tr("Edit Gradient…")));
|
||||
if r.clicked() {
|
||||
actions.push(("prop.editGradient".into(), json!({"layer": lid, "prop": p.uid})));
|
||||
app.dialog_state.layer_style.gradient_open = true;
|
||||
}
|
||||
app.auto.add(&format!("dialog.layerStyle.value.{sel}/{path}"), r.rect, &p.name);
|
||||
ui.end_row();
|
||||
return;
|
||||
}
|
||||
let edit = ui.scope(|ui| control(ui, p, &v));
|
||||
let path = g.match_path_of(p.uid).unwrap_or_default();
|
||||
if let Some(nv) = edit.inner {
|
||||
|
||||
@@ -155,7 +155,7 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
ui.end_row();
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
let r = ui.checkbox(&mut d.cue, tr("Cue Point"));
|
||||
let r = crate::widgets::labelled_checkbox(ui, &mut d.cue, tr("Cue Point"));
|
||||
app.auto.add("dialog.marker.cue", r.rect, "Cue Point");
|
||||
ui.add_enabled_ui(d.cue, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
@@ -191,7 +191,7 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.checkbox(&mut d.protected, tr("Protected Region"));
|
||||
let r = crate::widgets::labelled_checkbox(ui, &mut d.protected, tr("Protected Region"));
|
||||
app.auto.add("dialog.marker.protected", r.rect, "Protected Region");
|
||||
ui.add_space(20.0);
|
||||
ui.label(tr("Label:"));
|
||||
|
||||
@@ -264,7 +264,7 @@ fn fmt_rate(r: f64) -> String {
|
||||
fn check_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, at: egui::Pos2, key: &str, text: &str, on: bool) -> bool {
|
||||
let t = app.tokens;
|
||||
let r = Rect::from_min_size(pos2(at.x, at.y - 8.0), vec2(16.0, 16.0));
|
||||
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::unique(("pv-check", key))).clicked();
|
||||
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::unique(("pv-check", key))).changed();
|
||||
p.text(pos2(r.max.x + 5.0, at.y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text);
|
||||
app.auto.add(&format!("preview.{key}"), r, text);
|
||||
clicked
|
||||
|
||||
@@ -239,7 +239,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
.enumerate()
|
||||
{
|
||||
let r = Rect::from_min_size(pos2(x0 + i as f32 * (w / 2.0), y), vec2(18.0, 20.0));
|
||||
if widgets::checkbox(ui, r, on, &t, egui::Id::unique(key)).clicked() {
|
||||
if widgets::checkbox(ui, r, on, &t, egui::Id::unique(key)).changed() {
|
||||
set(app, json!({k: !on}));
|
||||
}
|
||||
p.text(pos2(r.max.x + 4.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim);
|
||||
@@ -273,7 +273,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
y += 26.0;
|
||||
let r = Rect::from_min_size(pos2(x0, y), vec2(18.0, 20.0));
|
||||
if widgets::checkbox(ui, r, o.show_overlay, &t, egui::Id::unique("paint.showOverlay")).clicked() {
|
||||
if widgets::checkbox(ui, r, o.show_overlay, &t, egui::Id::unique("paint.showOverlay")).changed() {
|
||||
set(app, json!({"showOverlay": !o.show_overlay}));
|
||||
}
|
||||
app.auto.add("paint.showOverlay", r, "Clone Source Overlay");
|
||||
@@ -282,7 +282,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
set(app, json!({"overlayOpacity": nv}));
|
||||
}
|
||||
let dr = Rect::from_min_size(pos2(x0 + w - 18.0, y), vec2(18.0, 20.0));
|
||||
if widgets::checkbox(ui, dr, o.overlay_difference, &t, egui::Id::unique("paint.overlayDifference")).clicked() {
|
||||
if widgets::checkbox(ui, dr, o.overlay_difference, &t, egui::Id::unique("paint.overlayDifference")).changed() {
|
||||
set(app, json!({"overlayDifference": !o.overlay_difference}));
|
||||
}
|
||||
app.auto.add("paint.overlayDifference", dr, "Difference mode overlay");
|
||||
|
||||
@@ -23,7 +23,7 @@ pub fn dropdown(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, auto: &str
|
||||
pub fn checkbox(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, text: &str, on: bool) -> bool {
|
||||
let t = app.tokens;
|
||||
let r = Rect::from_min_size(at, vec2(16.0, 16.0));
|
||||
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::unique(("kit-cb", auto))).clicked();
|
||||
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::unique(("kit-cb", auto))).changed();
|
||||
ui.painter().text(pos2(r.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text);
|
||||
let full = Rect::from_min_size(at, vec2(22.0 + text.len() as f32 * 6.5, 16.0));
|
||||
app.auto.add(auto, full, text);
|
||||
|
||||
@@ -54,7 +54,7 @@ pub fn create_nulls(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
acts.push((id, if id == "paths.tracePath" { json!({"loop": looping}) } else { json!({}) }));
|
||||
}
|
||||
}
|
||||
let r = ui.checkbox(&mut looping, tr("Loop (Trace Path)"));
|
||||
let r = crate::widgets::labelled_checkbox(&mut ui, &mut looping, tr("Loop (Trace Path)"));
|
||||
app.auto.add("createNulls.loop", r.rect, "Loop");
|
||||
ui.data_mut(|d| d.insert_temp(loop_id, looping));
|
||||
run(app, &ctx, acts);
|
||||
|
||||
@@ -141,11 +141,11 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.add_enabled_ui(!d.leave, |ui| {
|
||||
let r = ui.checkbox(&mut d.adjust, tr("Adjust composition duration to the time span of the selected layers"));
|
||||
let r = crate::widgets::labelled_checkbox(ui, &mut d.adjust, tr("Adjust composition duration to the time span of the selected layers"));
|
||||
app.auto.add("dialog.precompose.adjust", r.rect, "Adjust composition duration");
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
let r = ui.checkbox(&mut d.open, tr("Open New Composition"));
|
||||
let r = crate::widgets::labelled_checkbox(ui, &mut d.open, tr("Open New Composition"));
|
||||
app.auto.add("dialog.precompose.open", r.rect, "Open New Composition");
|
||||
ui.add_space(14.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
|
||||
@@ -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<String> = RenderLog::ALL.iter().map(|l| mark(it.log == *l, l.label())).collect();
|
||||
if let Some(i) = widgets::popup_menu(ui, lid, ld.left_bottom(), &llabels, None) {
|
||||
if widgets::by_keyboard(ui) {
|
||||
log_resp.request_focus();
|
||||
}
|
||||
actions.push(("renderQueue.setLog", json!({"item": it.id, "log": RenderLog::ALL[i].label()})));
|
||||
}
|
||||
app.auto.add(&aid("log"), ld, it.log.label());
|
||||
@@ -630,13 +647,23 @@ fn output_to(
|
||||
lp.text(pos2(ox, r.center().y), Align2::LEFT_CENTER, tr("Output To:"), Tokens::ui(11.5), t.text_dim);
|
||||
let odd = Rect::from_min_size(pos2(ox + 62.0, r.min.y + 3.0), vec2(22.0, ROW_H - 6.0));
|
||||
let oid = egui::Id::unique(("rq-out", it.id));
|
||||
if widgets::dropdown(ui, odd, "", &t, oid).clicked() && !rendering {
|
||||
let out_resp = widgets::dropdown(ui, odd, "", &t, oid);
|
||||
a11y::combo_box(
|
||||
&out_resp,
|
||||
tr("Output To:").trim_end_matches(':'),
|
||||
&app.session.resolve_output(it).unwrap_or_else(|| "Not yet specified".into()),
|
||||
!rendering,
|
||||
);
|
||||
if out_resp.clicked() && !rendering {
|
||||
widgets::open_popup(ui, oid);
|
||||
}
|
||||
app.auto.add(&aid("outputTo"), odd, "Output To");
|
||||
let omenu = output_to_menu(it);
|
||||
let olabels: Vec<String> = omenu.iter().map(|(l, _)| l.clone()).collect();
|
||||
if let Some(i) = widgets::popup_menu(ui, oid, odd.left_bottom(), &olabels, None) {
|
||||
if widgets::by_keyboard(ui) {
|
||||
out_resp.request_focus();
|
||||
}
|
||||
if omenu[i].1.get("choose").is_some() {
|
||||
match pick_output(app, it) {
|
||||
Some(Some(path)) => actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": path}))),
|
||||
@@ -654,6 +681,7 @@ fn output_to(
|
||||
let mut buf = editing.as_ref().map(|(_, b)| b.clone()).unwrap_or_default();
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(path_rect));
|
||||
let resp = child.add(egui::TextEdit::singleline(&mut buf).desired_width(path_rect.width()).font(Tokens::ui(11.5)));
|
||||
a11y::text_input(&resp, tr("Output file name template"), &buf);
|
||||
if resp.lost_focus() {
|
||||
if ui.input(|i| i.key_pressed(egui::Key::Enter)) && !buf.trim().is_empty() {
|
||||
actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": buf.trim()})));
|
||||
@@ -666,6 +694,7 @@ fn output_to(
|
||||
} else {
|
||||
let shown = app.session.resolve_output(it).unwrap_or_else(|| "Not yet specified".into());
|
||||
let resp = ui.interact(path_rect, egui::Id::unique(("rq-path", it.id)), Sense::click());
|
||||
a11y::button(&resp, &tr_args("Output file: {}", &[&shown]), !rendering);
|
||||
let col = if resp.hovered() { t.accent_hover } else { t.hot_text };
|
||||
lp.with_clip_rect(path_rect.intersect(list)).text(pos2(path_rect.min.x, r.center().y), Align2::LEFT_CENTER, &shown, Tokens::ui(11.5), col);
|
||||
// Clicking the file name opens the save dialog (Output Movie To); Alt-click, or a
|
||||
@@ -687,19 +716,113 @@ fn output_to(
|
||||
|
||||
/// Delete removes the selected item when the panel has focus (`allowed`: not editing a path and
|
||||
/// not rendering).
|
||||
fn delete_selected(app: &EffectcraftApp, ui: &egui::Ui, allowed: bool, selected: &mut Option<u64>, actions: &mut Vec<Action>) {
|
||||
fn delete_selected(app: &EffectcraftApp, ui: &egui::Ui, allowed: bool, on_row: bool, selected: &mut Option<u64>, actions: &mut Vec<Action>) {
|
||||
if app.ui.focused == crate::dock::PanelKind::RenderQueue
|
||||
&& allowed
|
||||
&& let Some(id) = *selected
|
||||
&& app.dialog.is_none()
|
||||
&& !crate::widgets::any_menu_open(ui.ctx())
|
||||
&& ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace))
|
||||
&& !ui.ctx().egui_wants_keyboard_input()
|
||||
// (A focused queue row is not typing; any other focused widget may be.)
|
||||
&& (on_row || !ui.ctx().egui_wants_keyboard_input())
|
||||
{
|
||||
actions.push(("renderQueue.remove", json!({"item": id})));
|
||||
*selected = None;
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard: Up / Down move between the queued items, Home / End go to the first / last, Right
|
||||
// and Left open and close the item's settings, Enter and Space do the same (Space only with a
|
||||
// row focused: otherwise it plays the preview). The focus follows the selection.
|
||||
///
|
||||
/// Returns whether a queue row has keyboard focus.
|
||||
fn list_keys(
|
||||
app: &mut EffectcraftApp,
|
||||
ctx: &egui::Context,
|
||||
queue: &[RenderQueueItem],
|
||||
(open, selected): (&mut Vec<u64>, &mut Option<u64>),
|
||||
can_navigate: bool,
|
||||
tops: &[(u64, f32)],
|
||||
list_h: f32,
|
||||
) -> bool {
|
||||
let focused = ctx.memory(|m| m.focused());
|
||||
let on_row = focused.is_some_and(|f| queue.iter().any(|i| row_id(i.id) == f));
|
||||
let by_mouse = focused.is_none() && app.ui.focused == crate::dock::PanelKind::RenderQueue;
|
||||
if (on_row || by_mouse) && can_navigate && app.dialog.is_none() && !widgets::any_menu_open(ctx) {
|
||||
let keys: Vec<egui::Key> = ctx.input(|i| {
|
||||
i.events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
egui::Event::Key { key, pressed: true, modifiers, .. } if !modifiers.any() => Some(*key),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let mut moved = None;
|
||||
for key in keys {
|
||||
use egui::Key;
|
||||
let at = (*selected).and_then(|s| queue.iter().position(|i| i.id == s));
|
||||
let Some(last) = queue.len().checked_sub(1) else { break };
|
||||
let to = match key {
|
||||
Key::ArrowDown => Some(at.map_or(0, |a| a.saturating_add(1).min(last))),
|
||||
Key::ArrowUp => Some(at.map_or(last, |a| a.saturating_sub(1))),
|
||||
Key::Home if on_row => Some(0),
|
||||
Key::End if on_row => Some(last),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(item) = to.and_then(|to| queue.get(to)) {
|
||||
*selected = Some(item.id);
|
||||
moved = Some(item.id);
|
||||
continue;
|
||||
}
|
||||
let Some(item) = (*selected).filter(|s| queue.iter().any(|i| i.id == *s)) else { continue };
|
||||
match key {
|
||||
Key::ArrowRight if !open.contains(&item) => open.push(item),
|
||||
Key::ArrowLeft => open.retain(|i| *i != item),
|
||||
Key::Enter | Key::Space if on_row || key == Key::Enter => {
|
||||
if open.contains(&item) {
|
||||
open.retain(|i| *i != item);
|
||||
} else {
|
||||
open.push(item);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(item) = moved {
|
||||
if let Some((_, top)) = tops.iter().find(|(id, _)| *id == item) {
|
||||
widgets::PanelScroll::reveal(ctx, egui::Id::unique("rq-scroll"), *top, *top + ROW_H, list_h);
|
||||
}
|
||||
app.ui.focused = crate::dock::PanelKind::RenderQueue;
|
||||
if on_row {
|
||||
a11y::focus_next_pass(ctx, "rq", row_id(item));
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
// The arrows are the queue's, not egui's spatial focus moves.
|
||||
if on_row {
|
||||
ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None));
|
||||
}
|
||||
}
|
||||
on_row
|
||||
}
|
||||
|
||||
/// An item's Status cell: its state, or the percentage while it is the one rendering.
|
||||
fn status_text(it: &RenderQueueItem, progress: Option<&effectcraft_engine::render_queue::JobState>, rendering: bool) -> String {
|
||||
match progress.filter(|p| rendering && p.current == Some(it.id)) {
|
||||
Some(pr) => format!("Rendering {:.0}%", if pr.total > 0 { pr.done as f64 * 100.0 / pr.total as f64 } else { 0.0 }),
|
||||
None => it.status.label().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// An item's Render Time cell: the running time while it renders, else its last render time.
|
||||
fn render_time_text(it: &RenderQueueItem, progress: Option<&effectcraft_engine::render_queue::JobState>, rendering: bool) -> String {
|
||||
match progress.filter(|p| rendering && p.current == Some(it.id)) {
|
||||
Some(p) => hms(p.item_elapsed),
|
||||
None => it.render_time.map(|s| if s < 60.0 { format!("{s:.1} s") } else { hms(s) }).unwrap_or_else(|| "-".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Render Queue panel: the Current Render strip (elapsed and remaining time, Render/Stop and
|
||||
/// Notify, plus the progress bar), the column header, the queued items with their output template,
|
||||
/// status and timings, and the footer totals. Comps dragged from the Project are queued on
|
||||
@@ -760,6 +883,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if queue.is_empty() {
|
||||
lp.text(pos2(list.center().x, list.min.y + 30.0), Align2::CENTER_CENTER, tr("The render queue is empty."), Tokens::ui(12.0), t.text_faint);
|
||||
}
|
||||
let ctx = ui.ctx().clone();
|
||||
// Where each item's row starts in the scrolled content, for the keys.
|
||||
let mut tops: Vec<(u64, f32)> = Vec::new();
|
||||
let editing_id = egui::Id::unique("rq-edit-output");
|
||||
let mut editing: Option<(u64, String)> = ui.data(|d| d.get_temp(editing_id));
|
||||
for (k, it) in queue.iter().enumerate() {
|
||||
@@ -779,16 +905,27 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
t.row_alt
|
||||
},
|
||||
);
|
||||
let row_resp = ui.interact(row, egui::Id::unique(("rq-row", it.id)), Sense::click());
|
||||
if row_resp.clicked() {
|
||||
let row_resp = ui.interact(row, row_id(it.id), Sense::click());
|
||||
tops.push((it.id, row.min.y - list.min.y + scroll.offset));
|
||||
if a11y::take_pending_focus(&ctx, "rq", row_resp.id) {
|
||||
row_resp.request_focus();
|
||||
}
|
||||
if row_resp.clicked() || row_resp.gained_focus() {
|
||||
selected = Some(it.id);
|
||||
app.ui.focused = crate::dock::PanelKind::RenderQueue;
|
||||
}
|
||||
if row_resp.has_focus() {
|
||||
lp.rect_stroke(row.shrink(0.5), 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside);
|
||||
}
|
||||
let comp_item = app.session.project.item(it.comp);
|
||||
let name = comp_item.map(|i| i.name.clone()).unwrap_or_else(|| "(missing composition)".into());
|
||||
crate::widgets::context_menu(&row_resp, |ui| row_menu(ui, &mut actions, it.id, k, queue.len()));
|
||||
app.auto.add(&aid("row"), row, &format!("#{n}"));
|
||||
let is_open = open.contains(&it.id);
|
||||
let tw = Rect::from_center_size(pos2(cx_twirl + 8.0, row.center().y), vec2(14.0, 14.0));
|
||||
if widgets::twirl(ui, tw, is_open, egui::Id::unique(("rq-twirl", it.id)), &t).clicked() {
|
||||
let tw_resp = widgets::twirl(ui, tw, is_open, egui::Id::unique(("rq-twirl", it.id)), &t);
|
||||
a11y::disclosure(&tw_resp, &tr_args(if is_open { "Hide details of {}" } else { "Show details of {}" }, &[&name]), is_open);
|
||||
if tw_resp.clicked() {
|
||||
if is_open {
|
||||
open.retain(|i| *i != it.id);
|
||||
} else {
|
||||
@@ -797,37 +934,27 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
app.auto.add(&aid("twirl"), tw, "Twirl");
|
||||
let cb = Rect::from_center_size(pos2(cx_render + 20.0, row.center().y), vec2(18.0, 18.0));
|
||||
if widgets::checkbox(ui, cb, it.render, &t, egui::Id::unique(("rq-cb", it.id))).clicked() && !rendering {
|
||||
let cb_resp = ui.add_enabled_ui(!rendering, |ui| widgets::checkbox(ui, cb, it.render, &t, egui::Id::unique(("rq-cb", it.id)))).inner;
|
||||
a11y::check_box(&cb_resp, &format!("{}: {name}", tr("Render")), it.render, !rendering);
|
||||
if cb_resp.changed() && !rendering {
|
||||
actions.push(("renderQueue.setRender", json!({"item": it.id, "render": !it.render})));
|
||||
}
|
||||
app.auto.add(&aid("render"), cb, if it.render { "Render: on" } else { "Render: off" });
|
||||
let comp_item = app.session.project.item(it.comp);
|
||||
let label_col = comp_item.map(|i| t.label(i.label)).unwrap_or(t.text_faint);
|
||||
lp.rect_filled(Rect::from_center_size(pos2(cx_label + 6.0, row.center().y), vec2(10.0, 10.0)), 2.0, label_col);
|
||||
lp.text(pos2(cx_num + 2.0, row.center().y), Align2::LEFT_CENTER, n.to_string(), Tokens::ui(12.0), t.text_dim);
|
||||
let name = comp_item.map(|i| i.name.clone()).unwrap_or_else(|| "(missing composition)".into());
|
||||
lp.text(pos2(cx_name + 2.0, row.center().y), Align2::LEFT_CENTER, &name, Tokens::ui(12.0), t.text);
|
||||
let status = if rendering && let Some(pr) = progress.as_ref().filter(|p| p.current == Some(it.id)) {
|
||||
format!("Rendering {:.0}%", if pr.total > 0 { pr.done as f64 * 100.0 / pr.total as f64 } else { 0.0 })
|
||||
} else {
|
||||
it.status.label().to_string()
|
||||
};
|
||||
let status = status_text(it, progress.as_ref(), rendering);
|
||||
let st_rect = Rect::from_min_size(pos2(cx_status, row.min.y), vec2(w_status, ROW_H));
|
||||
lp.text(pos2(cx_status + 2.0, row.center().y), Align2::LEFT_CENTER, &status, Tokens::ui(12.0), status_color(&t, &it.status));
|
||||
if let RenderStatus::Failed(e) = &it.status {
|
||||
ui.interact(st_rect, egui::Id::unique(("rq-st", it.id)), Sense::hover()).on_hover_text(e);
|
||||
}
|
||||
app.auto.add(&aid("status"), st_rect, &status);
|
||||
if let Some(s) = it.started {
|
||||
lp.text(pos2(cx_started + 2.0, row.center().y), Align2::LEFT_CENTER, utc_stamp(s), Tokens::ui(11.5), t.text_dim);
|
||||
} else {
|
||||
lp.text(pos2(cx_started + 2.0, row.center().y), Align2::LEFT_CENTER, "-", Tokens::ui(11.5), t.text_faint);
|
||||
}
|
||||
let rt = if progress.as_ref().and_then(|p| p.current) == Some(it.id) && rendering {
|
||||
progress.as_ref().map(|p| hms(p.item_elapsed)).unwrap_or_default()
|
||||
} else {
|
||||
it.render_time.map(|s| if s < 60.0 { format!("{s:.1} s") } else { hms(s) }).unwrap_or_else(|| "-".into())
|
||||
};
|
||||
a11y::tree_item(&row_resp, &format!("{n}. {name}, {status}"), is_sel, 1, Some(is_open));
|
||||
let (started, started_col) = it.started.map_or(("-".to_string(), t.text_faint), |s| (utc_stamp(s), t.text_dim));
|
||||
lp.text(pos2(cx_started + 2.0, row.center().y), Align2::LEFT_CENTER, started, Tokens::ui(11.5), started_col);
|
||||
let rt = render_time_text(it, progress.as_ref(), rendering);
|
||||
lp.text(pos2(cx_time + 2.0, row.center().y), Align2::LEFT_CENTER, rt, Tokens::ui(11.5), t.text_dim);
|
||||
|
||||
if !is_open {
|
||||
@@ -849,11 +976,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let dd = Rect::from_min_size(pos2(ix + 70.0, r.min.y + 3.0), vec2(220.0, ROW_H - 6.0));
|
||||
let did = egui::Id::unique(("rq-dd", it.id, sub));
|
||||
let shown: String = if summary.chars().count() > 31 { summary.chars().take(30).chain(std::iter::once('…')).collect() } else { summary.clone() };
|
||||
if widgets::dropdown(ui, dd, &shown, &t, did).clicked() && !rendering {
|
||||
let dd_resp = widgets::dropdown(ui, dd, &shown, &t, did);
|
||||
a11y::combo_box(&dd_resp, label.trim_end_matches(':'), &summary, !rendering);
|
||||
if dd_resp.clicked() && !rendering {
|
||||
widgets::open_popup(ui, did);
|
||||
}
|
||||
let labels: Vec<String> = menu.iter().map(|(l, _)| l.clone()).collect();
|
||||
if let Some(i) = widgets::popup_menu(ui, did, dd.left_bottom(), &labels, None) {
|
||||
if widgets::by_keyboard(ui) {
|
||||
dd_resp.request_focus();
|
||||
}
|
||||
let mut params = menu[i].1.clone();
|
||||
if let Some(f) = params.get("form").and_then(Value::as_str) {
|
||||
forms.push((it.clone(), f.to_string()));
|
||||
@@ -893,14 +1025,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
None => d.remove::<(u64, String)>(editing_id),
|
||||
});
|
||||
|
||||
delete_selected(app, ui, editing.is_none() && !rendering, &mut selected, &mut actions);
|
||||
let on_row = list_keys(app, &ctx, &queue, (&mut open, &mut selected), editing.is_none(), &tops, list.height());
|
||||
|
||||
delete_selected(app, ui, editing.is_none() && !rendering, on_row, &mut selected, &mut actions);
|
||||
|
||||
// ---------------------------------------------------------- footer
|
||||
footer(&p, rect, &rq, &t);
|
||||
|
||||
open.retain(|id| queue.iter().any(|i| i.id == *id));
|
||||
ui.data_mut(|d| d.insert_temp(state_id, (open, selected)));
|
||||
let ctx = ui.ctx().clone();
|
||||
for (it, f) in forms {
|
||||
open_menu_form(app, &it, &f);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use effectcraft_engine::project::render_templates::{RenderTemplates, TemplateKin
|
||||
use egui::{Color32, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::a11y;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{Dialog, EffectcraftApp};
|
||||
|
||||
@@ -65,6 +66,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
ui.selectable_value(&mut pick, n.clone(), n);
|
||||
}
|
||||
});
|
||||
a11y::combo_box(&r.response, slot.label(), &cur, true);
|
||||
app.auto.add(&format!("templates.default.{slot:?}"), r.response.rect, &cur);
|
||||
if pick != cur {
|
||||
actions.push(("renderQueue.setTemplateDefault", json!({"kind": kind, "slot": format!("{slot:?}"), "name": pick})));
|
||||
@@ -82,6 +84,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
ui.selectable_value(&mut st.selected, n.clone(), n);
|
||||
}
|
||||
});
|
||||
a11y::combo_box(&r.response, tr("Settings Name:").trim_end_matches(':'), &st.selected, true);
|
||||
app.auto.add("templates.select", r.response.rect, &st.selected);
|
||||
});
|
||||
let builtin = RenderTemplates::is_builtin(k, &st.selected)
|
||||
@@ -137,6 +140,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tr("New from Render Queue item:"));
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut st.new_name).hint_text(tr("template name")).desired_width(180.0));
|
||||
a11y::text_input(&r, tr("template name"), &st.new_name);
|
||||
app.auto.add("templates.newName", r.rect, &st.new_name);
|
||||
let item = queue.last().map(|i| i.id);
|
||||
let ok = item.is_some() && !st.new_name.trim().is_empty();
|
||||
|
||||
@@ -13,6 +13,7 @@ use effectcraft_engine::segment::Task;
|
||||
use egui::{Color32, RichText, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::a11y;
|
||||
use crate::theme::{ThemeKind, Tokens};
|
||||
use crate::{Dialog, EffectcraftApp};
|
||||
|
||||
@@ -78,6 +79,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(label_w, 18.0), egui::Label::new(tr("Default Output:")));
|
||||
let shown = if sel.is_empty() { tr("System Default").to_string() } else { sel.clone() };
|
||||
let shown_name = shown.clone();
|
||||
let r = craft_ui::ComboBox::from_id_salt(("settings", key)).selected_text(shown).width(260.0).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(sel.is_empty(), tr("System Default")).clicked() {
|
||||
acts.push(Act::Set(key.into(), json!("")));
|
||||
@@ -88,6 +90,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
|
||||
}
|
||||
}
|
||||
});
|
||||
a11y::combo_box(&r.response, tr("Default Output:").trim_end_matches(':'), &shown_name, true);
|
||||
reg(app, &format!("settings.{key}"), &r.response, "Default Output");
|
||||
});
|
||||
if devices.is_empty() {
|
||||
@@ -103,10 +106,12 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
|
||||
let id = format!("settings.{key}");
|
||||
let tip = (!live).then(|| tr("Stored, but not used by EffectCraft yet"));
|
||||
let mut resp: Option<egui::Response> = None;
|
||||
// What the control is set to, for assistive technology.
|
||||
let mut value_text = String::new();
|
||||
match kind {
|
||||
Kind::Bool => {
|
||||
let mut b = v.as_bool().unwrap_or(false);
|
||||
let r = ui.checkbox(&mut b, tr(label));
|
||||
let r = crate::widgets::labelled_checkbox(ui, &mut b, tr(label));
|
||||
if r.changed() {
|
||||
acts.push(Act::Set(key.into(), json!(b)));
|
||||
}
|
||||
@@ -162,6 +167,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
|
||||
Kind::Choice(opts) => {
|
||||
let curv = v.as_str().map_or_else(|| v.to_string(), str::to_string);
|
||||
let shown = opts.iter().find(|o| o.1 == curv).map(|o| o.0).unwrap_or(curv.as_str()).to_string();
|
||||
value_text = tr(&shown).to_string();
|
||||
craft_ui::ComboBox::from_id_salt(("settings", key))
|
||||
.selected_text(tr(&shown))
|
||||
.width(220.0)
|
||||
@@ -177,6 +183,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
|
||||
Kind::Text | Kind::Path => {
|
||||
let mut s = v.as_str().unwrap_or("").to_string();
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(if kind == Kind::Path { 230.0 } else { 260.0 }));
|
||||
value_text = s.clone();
|
||||
if r.changed() {
|
||||
acts.push(Act::Set(key.into(), json!(s)));
|
||||
}
|
||||
@@ -194,6 +201,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
|
||||
let mut f = c.map(|v| v as f32 / 255.0);
|
||||
let r = crate::widgets::srgb_color_button(ui, &mut f);
|
||||
let rgb = if r.changed() { srgb_bytes(f) } else { c };
|
||||
value_text = hex(rgb);
|
||||
ui.label(RichText::new(hex(rgb)).monospace().color(t.text_dim));
|
||||
if rgb != c {
|
||||
acts.push(Act::Set(key.into(), json!(hex(rgb))));
|
||||
@@ -207,6 +215,17 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(r) = &resp {
|
||||
// The field's label is drawn beside it, not on it: give the control its name.
|
||||
let name = tr(label);
|
||||
match kind {
|
||||
Kind::Bool => {}
|
||||
Kind::Choice(_) => a11y::combo_box(r, name, &value_text, true),
|
||||
Kind::Text | Kind::Path => a11y::text_input(r, name, &value_text),
|
||||
Kind::Color => a11y::name_value(r, name, &value_text),
|
||||
_ => a11y::name(r, name),
|
||||
}
|
||||
}
|
||||
if let Some(r) = resp {
|
||||
let r = match tip {
|
||||
Some(tip) => r.on_hover_text(tip),
|
||||
@@ -395,6 +414,7 @@ fn appearance_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, t: &T
|
||||
}
|
||||
})
|
||||
.response;
|
||||
a11y::combo_box(&r, tr("Appearance Mode"), tr(shown), true);
|
||||
reg(app, "settings.appearance.appearanceMode", &r, "Appearance Mode");
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
@@ -618,12 +638,14 @@ fn labels_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, acts: &mu
|
||||
let mut f = c.map(|v| v as f32 / 255.0);
|
||||
let r = crate::widgets::srgb_color_button(ui, &mut f);
|
||||
let rgb = if r.changed() { srgb_bytes(f) } else { c };
|
||||
a11y::name_value(&r, &tr_args("Label {} color", &[&(i + 1)]), &hex(rgb));
|
||||
app.auto.add(&format!("settings.labels.{i}.color"), r.rect, "label colour");
|
||||
if rgb != c {
|
||||
acts.push(Act::Set(format!("labels.{i}.color"), json!(hex(rgb))));
|
||||
}
|
||||
let mut name = l["name"].as_str().unwrap_or("").to_string();
|
||||
let r = ui.add_sized(vec2(160.0, 20.0), egui::TextEdit::singleline(&mut name));
|
||||
a11y::text_input(&r, &tr_args("Label {} name", &[&(i + 1)]), &name);
|
||||
app.auto.add(&format!("settings.labels.{i}.name"), r.rect, "label name");
|
||||
if r.changed() {
|
||||
acts.push(Act::Set(format!("labels.{i}.name"), json!(name)));
|
||||
@@ -689,11 +711,21 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(page_width + 4.0);
|
||||
egui::ScrollArea::vertical().id_salt("settings-pages").max_height(body_h).auto_shrink([false, false]).show(ui, |ui| {
|
||||
// One Tab stop for the list (the open page); Up / Down / Home / End move
|
||||
// between the pages and the focus goes with them.
|
||||
// The rows touch, so the pointer never falls between two of them.
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
for p in &all {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(page_width, 24.0), egui::Sense::click());
|
||||
let ctx = ui.ctx().clone();
|
||||
let page_item = |id: &str| egui::Id::unique(("settings-page", id.to_string()));
|
||||
let on_list = all.iter().any(|q| ctx.memory(|m| m.has_focus(page_item(q.id))));
|
||||
for (n, p) in all.iter().enumerate() {
|
||||
let sel = page == p.id;
|
||||
let r = ui.allocate_space(vec2(page_width, 24.0)).1;
|
||||
let resp = ui.interact(r, page_item(p.id), if sel { egui::Sense::click() } else { egui::Sense::CLICK });
|
||||
a11y::list_item(&resp, crate::i18n::settings_page_title(p.title), sel, n + 1, all.len());
|
||||
if a11y::take_pending_focus(&ctx, "settings", resp.id) {
|
||||
resp.request_focus();
|
||||
}
|
||||
if sel {
|
||||
ui.painter().rect_filled(r, 3.0, t.accent);
|
||||
} else if resp.hovered() {
|
||||
@@ -708,11 +740,44 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
(page_width - 20.0).max(0.0),
|
||||
if sel { Color32::WHITE } else { t.text },
|
||||
);
|
||||
if resp.has_focus() {
|
||||
ui.painter().rect_stroke(r.shrink(1.0), 3.0, egui::Stroke::new(2.0, t.text), egui::StrokeKind::Inside);
|
||||
}
|
||||
app.auto.add(&format!("settings.page.{}", p.id), r, p.title);
|
||||
if resp.on_hover_text(crate::i18n::settings_page_title(p.title)).clicked() {
|
||||
page = p.id.to_string();
|
||||
}
|
||||
}
|
||||
if on_list {
|
||||
let at = all.iter().position(|q| q.id == page).unwrap_or(0);
|
||||
let last = all.len().saturating_sub(1);
|
||||
let mut to = at;
|
||||
for key in ctx.input(|i| {
|
||||
i.events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
egui::Event::Key { key, pressed: true, modifiers, .. } if !modifiers.any() => Some(*key),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}) {
|
||||
to = match key {
|
||||
egui::Key::ArrowDown => to.saturating_add(1).min(last),
|
||||
egui::Key::ArrowUp => to.saturating_sub(1),
|
||||
egui::Key::Home => 0,
|
||||
egui::Key::End => last,
|
||||
_ => to,
|
||||
};
|
||||
}
|
||||
if to != at
|
||||
&& let Some(q) = all.get(to)
|
||||
{
|
||||
page = q.id.to_string();
|
||||
a11y::focus_next_pass(&ctx, "settings", page_item(q.id));
|
||||
}
|
||||
// The arrows are the list's, not egui's spatial focus moves.
|
||||
ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None));
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
@@ -127,16 +127,21 @@ fn numeric_fields(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
|
||||
let row = i / 2;
|
||||
let fx = x0 + col as f32 * (w / 2.0);
|
||||
let fy = y + row as f32 * 28.0;
|
||||
p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
|
||||
let (r, nv, _) = widgets::hot_number_at(
|
||||
ui,
|
||||
pos2(fx + 26.0, fy),
|
||||
egui::Id::unique(("char", key)),
|
||||
v,
|
||||
&widgets::HotSpec { speed: 0.5, range, decimals: 0, suffix },
|
||||
&t,
|
||||
);
|
||||
let stroke = key == "strokeWidth";
|
||||
if stroke {
|
||||
// The stroke-width icon, as in After Effects, not a letter that reads as "width".
|
||||
crate::icons::paint(p, Rect::from_min_size(pos2(fx, fy + 1.0), vec2(18.0, 18.0)), crate::icons::Icon::StrokeWidth, t.text_dim);
|
||||
} else {
|
||||
p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
|
||||
}
|
||||
// Fractional stroke widths (the 0.25 / 0.5 / 0.75 presets) show their decimals.
|
||||
let decimals = if stroke && v.fract().abs() > 1e-9 { 2 } else { 0 };
|
||||
let (r, nv, _) =
|
||||
widgets::hot_number_at(ui, pos2(fx + 26.0, fy), egui::Id::unique(("char", key)), v, &widgets::HotSpec { speed: 0.5, range, decimals, suffix }, &t);
|
||||
app.auto.add(&format!("character.{key}"), r, key);
|
||||
if stroke {
|
||||
stroke_width_presets(app, ui, r, enabled, actions);
|
||||
}
|
||||
if let Some(nv) = nv
|
||||
&& enabled
|
||||
{
|
||||
@@ -145,6 +150,26 @@ fn numeric_fields(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
|
||||
}
|
||||
}
|
||||
|
||||
/// The stroke width's presets menu beside its value (`r`), as in After Effects.
|
||||
fn stroke_width_presets(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, enabled: bool, actions: &mut Vec<serde_json::Value>) {
|
||||
const PRESETS: [f64; 22] = [0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0];
|
||||
let t = app.tokens;
|
||||
let br = Rect::from_min_size(pos2(r.max.x + 4.0, r.min.y + 2.0), vec2(16.0, 16.0));
|
||||
let pop = egui::Id::unique("char-stroke-width-pop");
|
||||
if widgets::icon_button(ui, br, crate::icons::Icon::ChevronDown, false, &t, egui::Id::unique("char-stroke-width-presets"))
|
||||
.on_hover_text(tr("Stroke width presets"))
|
||||
.clicked()
|
||||
&& enabled
|
||||
{
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
app.auto.add("character.strokeWidth.presets", br, "Stroke width presets");
|
||||
let labels: Vec<String> = PRESETS.iter().map(|v| format!("{v} px")).collect();
|
||||
if let Some(v) = widgets::popup_menu(ui, pop, br.left_bottom(), &labels, None).and_then(|i| PRESETS.get(i)) {
|
||||
actions.push(json!({"strokeWidth": v}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Kerning: Metrics / Optical / a manual value (grid row 1, left).
|
||||
fn kerning_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, g: Grid, doc: &TextDoc, actions: &mut Vec<serde_json::Value>) {
|
||||
let t = app.tokens;
|
||||
@@ -297,7 +322,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
for (i, (key, label, on, c)) in [("fill", "Fill", doc.apply_fill, doc.fill), ("stroke", "Stroke", doc.apply_stroke, doc.stroke)].into_iter().enumerate() {
|
||||
let fx = x0 + i as f32 * (w / 2.0);
|
||||
let cr = Rect::from_min_size(pos2(fx, y + 3.0), vec2(14.0, 14.0));
|
||||
if widgets::checkbox(ui, cr, on, &t, egui::Id::unique(("char-apply", key))).clicked() && enabled {
|
||||
if ui.add_enabled_ui(enabled, |ui| widgets::checkbox(ui, cr, on, &t, egui::Id::unique(("char-apply", key)))).inner.changed() && enabled {
|
||||
actions.push(json!({if key == "fill" { "applyFill" } else { "applyStroke" }: !on}));
|
||||
}
|
||||
app.auto.add(&format!("character.{key}.enabled"), cr, label);
|
||||
@@ -350,7 +375,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
y += 32.0;
|
||||
// Ligatures.
|
||||
let lr = Rect::from_min_size(pos2(x0, y), vec2(14.0, 14.0));
|
||||
if widgets::checkbox(ui, lr, doc.ligatures, &t, egui::Id::unique("char-ligatures")).clicked() && enabled {
|
||||
if ui.add_enabled_ui(enabled, |ui| widgets::checkbox(ui, lr, doc.ligatures, &t, egui::Id::unique("char-ligatures"))).inner.changed() && enabled {
|
||||
actions.push(json!({"ligatures": !doc.ligatures}));
|
||||
}
|
||||
app.auto.add("character.ligatures", lr, "Ligatures");
|
||||
@@ -374,7 +399,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
.enumerate()
|
||||
{
|
||||
let cr = Rect::from_min_size(pos2(x0 + i as f32 * 120.0, y), vec2(14.0, 14.0));
|
||||
if widgets::checkbox(ui, cr, on, &t, egui::Id::unique(("char-vert", key))).clicked() && enabled {
|
||||
if ui.add_enabled_ui(enabled, |ui| widgets::checkbox(ui, cr, on, &t, egui::Id::unique(("char-vert", key)))).inner.changed() && enabled {
|
||||
actions.push(json!({key: !on}));
|
||||
}
|
||||
app.auto.add(&format!("character.{key}"), cr, label);
|
||||
@@ -677,7 +702,7 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
y += 30.0;
|
||||
let hr = Rect::from_min_size(pos2(x0, y), vec2(14.0, 14.0));
|
||||
if widgets::checkbox(ui, hr, doc.hanging_punctuation, &t, egui::Id::unique("para-hanging")).clicked() && enabled {
|
||||
if ui.add_enabled_ui(enabled, |ui| widgets::checkbox(ui, hr, doc.hanging_punctuation, &t, egui::Id::unique("para-hanging"))).inner.changed() && enabled {
|
||||
actions.push(json!({"hangingPunctuation": !doc.hanging_punctuation}));
|
||||
}
|
||||
app.auto.add("paragraph.hangingPunctuation", hr, "Roman Hanging Punctuation");
|
||||
@@ -777,11 +802,7 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui:
|
||||
ui.set_width(rect.width().max(180.0));
|
||||
// Filter row: a checkbox that keeps only the variable fonts.
|
||||
let filter_h = 22.0;
|
||||
let row = ui.add_sized([ui.available_width(), filter_h], egui::Button::selectable(false, ""));
|
||||
let box_rect = Rect::from_min_size(row.rect.min, vec2(18.0, filter_h));
|
||||
let label_rect = Rect::from_min_max(pos2(box_rect.max.x + 2.0, row.rect.min.y), row.rect.max);
|
||||
let boxed = widgets::checkbox(ui, box_rect, only_variable, &t, id.with("variable-check"));
|
||||
ui.painter().text(label_rect.left_center(), Align2::LEFT_CENTER, tr("Variable fonts only"), Tokens::ui(11.5), t.text);
|
||||
let row = ui.add_sized([ui.available_width(), filter_h], widgets::DragCheckbox::new(&mut only_variable, tr("Variable fonts only")));
|
||||
app_ref.auto.add(&format!("{auto}.variable"), row.rect, "Variable fonts only");
|
||||
// #1089: fonts installed after launch appear without restarting. The button is its own
|
||||
// row: the popup is only 180 px wide, so a control pinned to the filter row covers
|
||||
@@ -792,11 +813,13 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui:
|
||||
app_ref.ui.status = format!("{}…", tr("Rescan Fonts"));
|
||||
}
|
||||
app_ref.auto.add(&format!("{auto}.rescan"), resc, "Rescan Fonts");
|
||||
let toggled = row.clicked() || boxed.clicked();
|
||||
let toggled = row.changed();
|
||||
if toggled {
|
||||
only_variable = !only_variable;
|
||||
ui.data_mut(|d| d.insert_temp(variable_id, only_variable));
|
||||
// Typing goes on into the search field.
|
||||
}
|
||||
// The native checkbox can take focus on release after changing on press.
|
||||
// Typing goes on into the search field after either event.
|
||||
if toggled || row.clicked() {
|
||||
ui.memory_mut(|m| m.request_focus(search_id));
|
||||
}
|
||||
let query = state.query.trim().to_lowercase();
|
||||
|
||||
@@ -1778,7 +1778,7 @@ fn empty_timeline(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if let Some(crate::panels::DragPayload::Item(dragged)) = egui::DragAndDrop::payload::<crate::panels::DragPayload>(&ctx).as_deref()
|
||||
&& ui.rect_contains_pointer(rect)
|
||||
{
|
||||
let items: Vec<ItemId> = crate::panels::project_drop_items(app, *dragged)
|
||||
let items: Vec<ItemId> = crate::panels::project_drop_items(app, *dragged, None)
|
||||
.into_iter()
|
||||
.map(ItemId)
|
||||
.filter(|id| {
|
||||
@@ -3058,7 +3058,7 @@ fn layer_mode_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc:
|
||||
}
|
||||
let tr = Rect::from_center_size(pos2(cw.mode + 76.0 + 6.0, cy), vec2(14.0, 14.0));
|
||||
app.auto.add(&format!("timeline.layer.{}.preserveTransparency", layer.id.0), tr, "Preserve Transparency");
|
||||
if widgets::checkbox(ui, tr, layer.preserve_transparency, &t, egui::Id::unique(("pt", layer.id.0))).clicked() {
|
||||
if widgets::checkbox(ui, tr, layer.preserve_transparency, &t, egui::Id::unique(("pt", layer.id.0))).changed() {
|
||||
// The click gives the selection the state the clicked row toggles to,
|
||||
// whatever the first selected layer's value (as the switches do).
|
||||
actions.push((
|
||||
@@ -3565,7 +3565,7 @@ fn group_mask_mode(ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
|
||||
actions.push(("layer.setMask".into(), json!({"layer": layer.id.0, "mask": uid, "mode": opts[i]})));
|
||||
}
|
||||
let ir = Rect::from_min_size(pos2(mr.max.x + 8.0, cy - 8.0), vec2(16.0, 16.0));
|
||||
if widgets::checkbox(ui, ir, inverted, &t, egui::Id::unique(("minv", uid))).clicked() {
|
||||
if widgets::checkbox(ui, ir, inverted, &t, egui::Id::unique(("minv", uid))).changed() {
|
||||
actions.push(("layer.setMask".into(), json!({"layer": layer.id.0, "mask": uid, "inverted": !inverted})));
|
||||
}
|
||||
lp.text(pos2(ir.max.x + 4.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Inverted"), Tokens::ui(11.0), t.text_dim);
|
||||
@@ -4100,72 +4100,91 @@ fn key_double_click(g: &mut Rw, rc: &RowCtx<'_>, prop: &Property, kh: &KeyHit<'_
|
||||
/// The keyframe right-click menu.
|
||||
fn key_context_menu(app: &mut EffectcraftApp, g: &mut Rw, rc: &RowCtx<'_>, prop: &Property, kh: &KeyHit<'_>) {
|
||||
let KeyHit { k, ks, ref kresp, .. } = *kh;
|
||||
let t = g.tl.t;
|
||||
let actions = &mut g.tl.actions;
|
||||
let layer = rc.layer;
|
||||
let uid = &prop.uid;
|
||||
crate::widgets::context_menu(kresp, |ui| key_menu(ui, app, layer, prop, k, ks, actions));
|
||||
}
|
||||
|
||||
crate::widgets::context_menu(kresp, |ui| {
|
||||
for (lbl, cmd, params) in [
|
||||
("Edit Value…", "keys.set", json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()})),
|
||||
("-", "", json!({})),
|
||||
("Copy", "keys.copy", json!({})),
|
||||
("Paste", "keys.paste", json!({})),
|
||||
("-", "", json!({})),
|
||||
("Select Equal Keyframes", "keys.selectEqual", json!({})),
|
||||
("Select Previous Keyframes", "keys.selectPrevious", json!({})),
|
||||
("Select Following Keyframes", "keys.selectFollowing", json!({})),
|
||||
("-", "", json!({})),
|
||||
("Keyframe Interpolation…", "keys.interpolation", json!({})),
|
||||
("Keyframe Velocity…", "keys.velocity", json!({})),
|
||||
("Toggle Hold Keyframe", "keys.toggleHold", json!({})),
|
||||
(if k.muted { "Unmute Keyframes" } else { "Mute Keyframes" }, "keys.setMuted", json!({"muted": !k.muted})),
|
||||
("Rove Across Time", "keys.interpolation", json!({"roving": !k.roving})),
|
||||
("-", "", json!({})),
|
||||
("Easy Ease", "keys.easyEase", json!({})),
|
||||
("Easy Ease In", "keys.easyEaseIn", json!({})),
|
||||
("Easy Ease Out", "keys.easyEaseOut", json!({})),
|
||||
("Time-Reverse Keyframes", "keys.timeReverse", json!({})),
|
||||
("-", "", json!({})),
|
||||
("Linear", "keys.interpolation", json!({"interpolation": "linear"})),
|
||||
("Bezier", "keys.interpolation", json!({"interpolation": "bezier"})),
|
||||
("Auto Bezier", "keys.interpolation", json!({"interpolation": "autoBezier"})),
|
||||
("Select All Keyframes", "keys.selectAll", json!({})),
|
||||
("Delete", "keys.delete", json!({})),
|
||||
] {
|
||||
if lbl == "Select All Keyframes" {
|
||||
// Keyframe colour labels.
|
||||
/// The commands of a keyframe's right-click menu, for the Timeline and the Graph Editor alike. They act
|
||||
/// on the selected keys, so a menu opened on an unselected key selects it first. A locked layer's keys
|
||||
/// can still be copied and selected, but not edited.
|
||||
pub(crate) fn key_menu(
|
||||
ui: &mut egui::Ui,
|
||||
app: &EffectcraftApp,
|
||||
layer: &Layer,
|
||||
prop: &Property,
|
||||
k: &effectcraft_engine::keyframe::Keyframe,
|
||||
selected: bool,
|
||||
actions: &mut Vec<(String, serde_json::Value)>,
|
||||
) {
|
||||
let t = app.tokens;
|
||||
let locked = layer.switches.locked;
|
||||
let uid = &prop.uid;
|
||||
let this_key = json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()});
|
||||
for (lbl, cmd, params) in [
|
||||
("Edit Value…", "keys.set", this_key.clone()),
|
||||
("-", "", json!({})),
|
||||
("Copy", "keys.copy", json!({})),
|
||||
("Paste", "keys.paste", json!({})),
|
||||
("-", "", json!({})),
|
||||
("Select Equal Keyframes", "keys.selectEqual", json!({})),
|
||||
("Select Previous Keyframes", "keys.selectPrevious", json!({})),
|
||||
("Select Following Keyframes", "keys.selectFollowing", json!({})),
|
||||
("-", "", json!({})),
|
||||
("Keyframe Interpolation…", "keys.interpolation", json!({})),
|
||||
("Keyframe Velocity…", "keys.velocity", json!({})),
|
||||
("Toggle Hold Keyframe", "keys.toggleHold", json!({})),
|
||||
(if k.muted { "Unmute Keyframes" } else { "Mute Keyframes" }, "keys.setMuted", json!({"muted": !k.muted})),
|
||||
("Rove Across Time", "keys.interpolation", json!({"roving": !k.roving})),
|
||||
("-", "", json!({})),
|
||||
("Easy Ease", "keys.easyEase", json!({})),
|
||||
("Easy Ease In", "keys.easyEaseIn", json!({})),
|
||||
("Easy Ease Out", "keys.easyEaseOut", json!({})),
|
||||
("Time-Reverse Keyframes", "keys.timeReverse", json!({})),
|
||||
("-", "", json!({})),
|
||||
("Linear", "keys.interpolation", json!({"interpolation": "linear"})),
|
||||
("Bezier", "keys.interpolation", json!({"interpolation": "bezier"})),
|
||||
("Auto Bezier", "keys.interpolation", json!({"interpolation": "autoBezier"})),
|
||||
("Select All Keyframes", "keys.selectAll", json!({})),
|
||||
("Delete", "keys.delete", json!({})),
|
||||
] {
|
||||
if lbl == "Select All Keyframes" {
|
||||
// Keyframe colour labels.
|
||||
ui.add_enabled_ui(!locked, |ui| {
|
||||
ui.menu_button(crate::i18n::tr("Label"), |ui| {
|
||||
for (i, l) in effectcraft_engine::color::Label::ALL.into_iter().enumerate() {
|
||||
if widgets::label_entry(ui, l, &app.session.prefs.label_name(l), k.label as usize == i, &t).clicked() {
|
||||
if !ks {
|
||||
actions.push(("keys.select".into(), json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]})));
|
||||
if !selected {
|
||||
actions.push(("keys.select".into(), json!({"keys": [this_key.clone()]})));
|
||||
}
|
||||
actions.push(("keys.setLabel".into(), json!({"label": i})));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if lbl == "-" {
|
||||
ui.separator();
|
||||
continue;
|
||||
}
|
||||
if lbl == "Rove Across Time" && !prop.spatial {
|
||||
continue;
|
||||
}
|
||||
if lbl == "Edit Value…" && !matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
|
||||
continue;
|
||||
}
|
||||
if ui.button(lbl).clicked() {
|
||||
if !ks {
|
||||
actions.push(("keys.select".into(), json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]})));
|
||||
}
|
||||
actions.push((cmd.into(), params));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
if lbl == "-" {
|
||||
ui.separator();
|
||||
continue;
|
||||
}
|
||||
if lbl == "Rove Across Time" && !prop.spatial {
|
||||
continue;
|
||||
}
|
||||
if lbl == "Edit Value…" && !matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
|
||||
continue;
|
||||
}
|
||||
let edits = !matches!(cmd, "keys.copy" | "keys.selectEqual" | "keys.selectPrevious" | "keys.selectFollowing" | "keys.selectAll");
|
||||
// An unselected key is selected before the command runs, and locked keys cannot be selected.
|
||||
let unselectable = locked && !selected && cmd != "keys.selectAll";
|
||||
if ui.add_enabled(!(unselectable || locked && edits), egui::Button::new(crate::i18n::context_item(cmd, ¶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::<Vec<_>>());
|
||||
if levels {
|
||||
a["dims"] = json!([d]);
|
||||
a["follow"] = json!((from..from + shown).filter(|&e| e != d).collect::<Vec<_>>());
|
||||
} else {
|
||||
a["dims"] = json!((from..from + shown).collect::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
}
|
||||
x = r.max.x + if d + 1 < n { 2.0 } else { 0.0 };
|
||||
@@ -5754,8 +5794,8 @@ fn bool_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>,
|
||||
let t = app.tokens;
|
||||
let x = at.x;
|
||||
let r = Rect::from_min_size(pos2(x, at.y - 8.0), vec2(16.0, 16.0));
|
||||
if widgets::checkbox(ui, r, *b, &t, egui::Id::unique(("cb", uid))).clicked() {
|
||||
ve.set(actions, json!(!b));
|
||||
if widgets::checkbox(ui, r, *b, &t, egui::Id::unique(("cb", uid))).changed() {
|
||||
ve.set_step(actions, json!(!b));
|
||||
}
|
||||
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
|
||||
}
|
||||
@@ -5775,7 +5815,7 @@ fn enum_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>,
|
||||
}
|
||||
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
|
||||
if let Some(ni) = widgets::popup_menu(ui, pop, r.left_bottom(), &options, Some(*i as usize)) {
|
||||
ve.set(actions, json!(ni));
|
||||
ve.set_step(actions, json!(ni));
|
||||
}
|
||||
} else {
|
||||
p.text(pos2(x, at.y), Align2::LEFT_CENTER, fmt_num(*i as f64, 0), Tokens::ui(12.0), t.for_value(prop.has_expression()).hot_text);
|
||||
@@ -5795,7 +5835,7 @@ fn layer_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>,
|
||||
if let Some(comp) = app.session.active_comp_arc()
|
||||
&& let Some(chosen) = widgets::layer_picker(ui, r, &comp.layers, *l, &t, egui::Id::unique(("tl-lay", uid)))
|
||||
{
|
||||
ve.set(actions, chosen.map(|id| json!(id)).unwrap_or(serde_json::Value::Null));
|
||||
ve.set_step(actions, chosen.map(|id| json!(id)).unwrap_or(serde_json::Value::Null));
|
||||
}
|
||||
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
|
||||
}
|
||||
|
||||
@@ -540,6 +540,94 @@ fn disk_bar_range_covers_partial_frames() {
|
||||
let above_limit = TMap { start: 100_001.0, ..tm };
|
||||
assert_eq!(disk_bar_range(&above_limit, clip, 1.0, i64::MAX), 100_000..100_000);
|
||||
}
|
||||
|
||||
/// The keyframe menu is one for the Timeline and the Graph Editor: its commands are drawn in the
|
||||
/// interface language, and a locked layer's keys can be copied and selected but not edited.
|
||||
#[test]
|
||||
fn the_key_menu_is_translated_and_cannot_edit_a_locked_layers_keys() {
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
let mut s = effectcraft_engine::Session::default();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 64, "height": 64, "duration": 2})).unwrap();
|
||||
let id = s.execute("layer.newSolid", json!({"name": "Box", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
for (t, v) in [(0.0, 0), (1.0, 50)] {
|
||||
s.execute("prop.addKey", json!({"layer": id, "path": "transform/opacity", "time": t, "value": v})).unwrap();
|
||||
}
|
||||
let app = EffectcraftApp::new(s);
|
||||
let layer = app.session.active_comp().unwrap().layer(LayerId(id)).unwrap().clone();
|
||||
let prop = layer.props.prop("transform/opacity").unwrap().clone();
|
||||
let key = prop.keys[0].clone();
|
||||
let enabled = |locked: bool, selected: bool, label: &str| {
|
||||
let mut layer = layer.clone();
|
||||
layer.switches.locked = locked;
|
||||
let mut actions = vec![];
|
||||
let mut h = Harness::new_ui(|ui| key_menu(ui, &app, &layer, &prop, &key, selected, &mut actions));
|
||||
h.run();
|
||||
!h.get_by_label(label).accesskit_node().is_disabled()
|
||||
};
|
||||
for label in [
|
||||
"Copy",
|
||||
"Select Equal Keyframes",
|
||||
"Select All Keyframes",
|
||||
"Delete",
|
||||
"Edit Value…",
|
||||
"Easy Ease",
|
||||
"Time-Reverse Keyframes",
|
||||
"Keyframe Velocity…",
|
||||
"Mute Keyframes",
|
||||
] {
|
||||
assert!(enabled(false, true, label), "{label} on an unlocked layer");
|
||||
}
|
||||
for label in ["Copy", "Select Equal Keyframes", "Select All Keyframes"] {
|
||||
assert!(enabled(true, true, label), "{label} is still available on a locked layer");
|
||||
}
|
||||
for label in ["Delete", "Edit Value…", "Easy Ease", "Time-Reverse Keyframes", "Keyframe Velocity…", "Mute Keyframes", "Linear"] {
|
||||
assert!(!enabled(true, true, label), "{label} is off on a locked layer");
|
||||
assert!(!enabled(true, false, label), "{label} is off on an unselected locked key");
|
||||
}
|
||||
for label in ["Copy", "Select Equal Keyframes", "Select Previous Keyframes", "Select Following Keyframes"] {
|
||||
assert!(!enabled(true, false, label), "{label} requires selecting the clicked key");
|
||||
}
|
||||
assert!(enabled(true, false, "Select All Keyframes"));
|
||||
// The labels follow the interface language.
|
||||
crate::i18n::set_current("es");
|
||||
let copy = crate::i18n::context_item("keys.copy", &json!({}), "Copy");
|
||||
assert_ne!(copy, "Copy", "the catalog translates Copy");
|
||||
assert!(enabled(false, true, ©));
|
||||
crate::i18n::set_current("en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_menu_edit_selects_the_clicked_key_before_applying_the_command() {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
let mut s = effectcraft_engine::Session::default();
|
||||
s.execute("comp.new", json!({"width": 64, "height": 64, "duration": 2})).unwrap();
|
||||
let id = s.execute("layer.newSolid", json!({"color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
for (time, value) in [(0.0, 0), (1.0, 50)] {
|
||||
s.execute("prop.addKey", json!({"layer": id, "path": "transform/opacity", "time": time, "value": value})).unwrap();
|
||||
}
|
||||
let layer = s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone();
|
||||
let prop = layer.props.prop("transform/opacity").unwrap().clone();
|
||||
s.execute("keys.select", json!({"keys": [{"layer": id, "prop": prop.uid, "time": 1.0}]})).unwrap();
|
||||
let mut app = EffectcraftApp::new(s);
|
||||
let mut actions = vec![];
|
||||
{
|
||||
let mut h = Harness::new_ui(|ui| key_menu(ui, &app, &layer, &prop, &prop.keys[0], false, &mut actions));
|
||||
h.run();
|
||||
h.get_by_label("Easy Ease").click();
|
||||
h.run();
|
||||
}
|
||||
assert_eq!(actions.iter().map(|(command, _)| command.as_str()).collect::<Vec<_>>(), ["keys.select", "keys.easyEase"]);
|
||||
for (command, params) in actions {
|
||||
app.session.execute(&command, params).unwrap();
|
||||
}
|
||||
let keys = &app.session.active_comp().unwrap().layer(LayerId(id)).unwrap().props.find(prop.uid).unwrap().keys;
|
||||
assert_ne!(keys[0], prop.keys[0], "the clicked key was eased");
|
||||
assert_eq!(keys[1], prop.keys[1], "the previously selected key was not edited");
|
||||
assert_eq!(app.session.state.selected_keys.len(), 1);
|
||||
assert_eq!(app.session.state.selected_keys[0].time, prop.keys[0].time);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_drop_target_skips_moving_layers_beyond_the_viewport() {
|
||||
let mut rows: Vec<Row> = (1..=6).map(|id| Row { layer: LayerId(id), depth: 0, kind: RowKind::Layer }).collect();
|
||||
|
||||
@@ -257,10 +257,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
("scale", "Scale", settings.as_ref().is_some_and(|s| s.scale)),
|
||||
] {
|
||||
let cr = Rect::from_min_size(pos2(xx, y + 2.0), vec2(16.0, 16.0));
|
||||
let resp = widgets::checkbox(ui, cr, on && transformish, &t, egui::Id::unique(("tracker-cb", key)));
|
||||
let resp = ui.add_enabled_ui(transformish, |ui| widgets::checkbox(ui, cr, on && transformish, &t, egui::Id::unique(("tracker-cb", key)))).inner;
|
||||
app.auto.add(&format!("tracker.{key}"), cr, text);
|
||||
p.text(pos2(cr.max.x + 4.0, cr.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), if transformish { t.text } else { t.text_faint });
|
||||
if resp.clicked() && transformish {
|
||||
if resp.changed() && transformish {
|
||||
run(app, &ctx, "track.setType", with(json!({ key: !on })));
|
||||
}
|
||||
xx += 84.0;
|
||||
@@ -517,20 +517,20 @@ pub fn options_dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens)
|
||||
ui.add_space(8.0);
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("Process Before Match")).font(Tokens::medium(12.5)));
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.checkbox(&mut d.blur_on, crate::i18n::tr("Blur"));
|
||||
let r = crate::widgets::labelled_checkbox(ui, &mut d.blur_on, crate::i18n::tr("Blur"));
|
||||
app.auto.add("dialog.trackOptions.blurOn", r.rect, "Blur");
|
||||
let r =
|
||||
ui.add_enabled(d.blur_on, crate::widgets::drag_value(egui::DragValue::new(&mut d.opts.blur).speed(0.1).range(0.5..=50.0).suffix(" pixels")));
|
||||
app.auto.add("dialog.trackOptions.blur", r.rect, "Blur pixels");
|
||||
let r = ui.checkbox(&mut d.opts.enhance, crate::i18n::tr("Enhance"));
|
||||
let r = crate::widgets::labelled_checkbox(ui, &mut d.opts.enhance, crate::i18n::tr("Enhance"));
|
||||
app.auto.add("dialog.trackOptions.enhance", r.rect, "Enhance");
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
let r = ui.checkbox(&mut d.opts.subpixel, crate::i18n::tr("Subpixel Positioning"));
|
||||
let r = crate::widgets::labelled_checkbox(ui, &mut d.opts.subpixel, crate::i18n::tr("Subpixel Positioning"));
|
||||
app.auto.add("dialog.trackOptions.subpixel", r.rect, "Subpixel Positioning");
|
||||
let r = ui.checkbox(&mut d.opts.adapt_every_frame, crate::i18n::tr("Adapt Feature On Every Frame"));
|
||||
let r = crate::widgets::labelled_checkbox(ui, &mut d.opts.adapt_every_frame, crate::i18n::tr("Adapt Feature On Every Frame"));
|
||||
app.auto.add("dialog.trackOptions.adaptEveryFrame", r.rect, "Adapt Feature On Every Frame");
|
||||
let r = ui.checkbox(&mut d.opts.track_shape, crate::i18n::tr("Follow Feature Rotation and Scale"));
|
||||
let r = crate::widgets::labelled_checkbox(ui, &mut d.opts.track_shape, crate::i18n::tr("Follow Feature Rotation and Scale"));
|
||||
app.auto.add("dialog.trackOptions.trackShape", r.rect, "Follow Feature Rotation and Scale");
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ pub(super) fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec
|
||||
if let Some(crate::panels::DragPayload::Item(dragged)) = egui::DragAndDrop::payload::<crate::panels::DragPayload>(&ctx).as_deref()
|
||||
&& ui.rect_contains_pointer(rect)
|
||||
{
|
||||
let items: Vec<effectcraft_engine::project::ItemId> = crate::panels::project_drop_items(app, *dragged)
|
||||
let items: Vec<effectcraft_engine::project::ItemId> = crate::panels::project_drop_items(app, *dragged, None)
|
||||
.into_iter()
|
||||
.map(effectcraft_engine::project::ItemId)
|
||||
.filter(|id| {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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![],
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
use effectcraft_engine::geom::{Mat3, vec2 as gv2};
|
||||
use effectcraft_engine::keyframe::ShapePath;
|
||||
use effectcraft_engine::project::{GroupKind, Layer, LayerId};
|
||||
use effectcraft_engine::project::{GroupKind, Layer, LayerId, Property};
|
||||
use effectcraft_engine::render::EvalCtx;
|
||||
use effectcraft_engine::time::Tick;
|
||||
use effectcraft_engine::{KeyRef, VertexRef};
|
||||
@@ -309,6 +309,23 @@ pub(crate) struct KeyHit {
|
||||
pub value: [f64; 3],
|
||||
/// Parent space → comp.
|
||||
pub p2c: Mat3,
|
||||
/// Separated dimensions: X and Y Position's uids, and whether each has a key at `time`.
|
||||
pub axes: Option<[(u64, bool); 2]>,
|
||||
}
|
||||
|
||||
impl KeyHit {
|
||||
/// The keys this dot stands for: Position's, or the X and Y Position keys at its time.
|
||||
pub(crate) fn key_refs(&self) -> Vec<KeyRef> {
|
||||
match self.axes {
|
||||
Some(axes) => axes.iter().filter(|(_, has)| *has).map(|&(prop, _)| KeyRef { layer: self.layer, prop, time: self.time }).collect(),
|
||||
None => vec![KeyRef { layer: self.layer, prop: self.prop, time: self.time }],
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::key_refs`] as `keys.select` entries.
|
||||
pub(crate) fn select_entries(&self) -> Vec<serde_json::Value> {
|
||||
self.key_refs().iter().map(|k| json!({"layer": k.layer.0, "prop": k.prop, "time": k.time.seconds()})).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame on a motion path (one of its dots): where the Pen and Add Vertex tools add a
|
||||
@@ -320,6 +337,21 @@ pub(crate) struct PathFrame {
|
||||
/// Layer time of the frame.
|
||||
pub time: Tick,
|
||||
pub pos: Pos2,
|
||||
/// Separated dimensions: X and Y Position's uids, which get the key instead of `prop`.
|
||||
pub axes: Option<[u64; 2]>,
|
||||
}
|
||||
|
||||
/// Layer times of the keys of separated X and Y Position together, in order.
|
||||
fn separated_key_times(x: &Property, y: &Property) -> Vec<Tick> {
|
||||
let mut times: Vec<Tick> = x.keys.iter().chain(y.keys.iter()).map(|k| k.time).collect();
|
||||
times.sort();
|
||||
times.dedup();
|
||||
times
|
||||
}
|
||||
|
||||
/// Separated X and Y Position at layer time `t` (z 0: motion paths are for 2D layers).
|
||||
fn separated_value(x: &Property, y: &Property, t: Tick) -> [f64; 3] {
|
||||
[x.value_at(t).as_f64(), y.value_at(t).as_f64(), 0.0]
|
||||
}
|
||||
|
||||
/// The keyframe dot on a motion path under `pos`.
|
||||
@@ -361,44 +393,61 @@ pub(crate) fn motion_path(
|
||||
hits: &mut Vec<KeyHit>,
|
||||
frames: &mut Vec<PathFrame>,
|
||||
) {
|
||||
let Some(pos) = l.transform().and_then(|tr| tr.get("position")) else { return };
|
||||
if pos.keys.len() < 2 || l.is_3d() {
|
||||
let Some(tr) = l.transform() else { return };
|
||||
let Some(pos) = tr.get("position") else { return };
|
||||
// Separate Dimensions: the layer follows X / Y Position, so the path is drawn from their keys.
|
||||
let sep = tr.get("positionX").zip(tr.get("positionY"));
|
||||
let key_times: Vec<Tick> = match sep {
|
||||
Some((x, y)) => separated_key_times(x, y),
|
||||
None => pos.keys.iter().map(|k| k.time).collect(),
|
||||
};
|
||||
if key_times.len() < 2 || l.is_3d() {
|
||||
return;
|
||||
}
|
||||
let prop = sep.map_or(pos.uid, |(x, _)| x.uid);
|
||||
let frame_axes = sep.map(|(x, y)| [x.uid, y.uid]);
|
||||
let value_at = |t: Tick| match sep {
|
||||
Some((x, y)) => separated_value(x, y, t),
|
||||
None => pos.value_at(t).as_vec3(),
|
||||
};
|
||||
let p2c = parent_to_comp(ectx, l);
|
||||
let to_scr = |v: [f64; 3]| {
|
||||
let c = p2c.apply(gv2(v[0], v[1]));
|
||||
map.to_screen([c.x, c.y])
|
||||
};
|
||||
// Settings ▸ Composition ▸ Motion Path: all keyframes, none, or a window around the CTI.
|
||||
let times: Vec<Tick> = pos.keys.iter().map(|k| k.time).collect();
|
||||
let Some((lt0, lt1)) = app.session.prefs.motion_path_span(×, l.layer_time(ectx.time)) else { return };
|
||||
let Some((lt0, lt1)) = app.session.prefs.motion_path_span(&key_times, l.layer_time(ectx.time)) else { return };
|
||||
let fd = ectx.comp.frame_duration();
|
||||
let mut pts = vec![];
|
||||
let mut lt = lt0;
|
||||
while lt <= lt1 && pts.len() < 20_000 {
|
||||
let q = to_scr(pos.value_at(lt).as_vec3());
|
||||
frames.push(PathFrame { layer: l.id, prop: pos.uid, time: lt, pos: q });
|
||||
let q = to_scr(value_at(lt));
|
||||
frames.push(PathFrame { layer: l.id, prop, time: lt, pos: q, axes: frame_axes });
|
||||
pts.push(q);
|
||||
lt += fd;
|
||||
}
|
||||
pts.push(to_scr(pos.value_at(lt1).as_vec3()));
|
||||
pts.push(to_scr(value_at(lt1)));
|
||||
let faint = col.gamma_multiply(0.8);
|
||||
painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, faint)));
|
||||
for q in &pts {
|
||||
painter.circle_filled(*q, 1.2, faint);
|
||||
}
|
||||
let sel = &app.session.state.selected_keys;
|
||||
for (i, k) in pos.keys.iter().enumerate() {
|
||||
if k.time < lt0 || k.time > lt1 {
|
||||
for (i, kt) in key_times.iter().enumerate() {
|
||||
if *kt < lt0 || *kt > lt1 {
|
||||
continue;
|
||||
}
|
||||
let v = k.value.as_vec3();
|
||||
// The combined Position key at this time; none with separated dimensions.
|
||||
let key = pos.keys.get(i).filter(|_| sep.is_none());
|
||||
let v = key.map_or_else(|| value_at(*kt), |k| k.value.as_vec3());
|
||||
let s = to_scr(v);
|
||||
let selected = sel.contains(&KeyRef { layer: l.id, prop: pos.uid, time: k.time });
|
||||
let axes = sep.map(|(x, y)| [x, y].map(|a| (a.uid, a.keys.iter().any(|k| k.time == *kt))));
|
||||
let hit = KeyHit { layer: l.id, prop, time: *kt, pos: s, tangent: None, value: v, p2c, axes };
|
||||
let selected = hit.key_refs().iter().any(|k| sel.contains(k));
|
||||
// A muted position key is off the motion path: no tangents, and a dimmed point.
|
||||
let kc = if k.muted { col.gamma_multiply(if selected { 0.55 } else { 0.35 }) } else { col };
|
||||
if !k.muted {
|
||||
let kc = if key.is_some_and(|k| k.muted) { col.gamma_multiply(if selected { 0.55 } else { 0.35 }) } else { col };
|
||||
// Separated X / Y have no spatial tangents.
|
||||
if let Some(k) = key.filter(|k| !k.muted) {
|
||||
// Auto-Bezier keys show handles on a side the path leaves straight too (a two-key path's
|
||||
// ends), to drag it into a curve; Linear keys (no tangents) show none.
|
||||
let (tin, tout) = if k.spatial_auto {
|
||||
@@ -416,7 +465,7 @@ pub(crate) fn motion_path(
|
||||
painter.circle_filled(h, 3.0, kc);
|
||||
let side = if out { "out" } else { "in" };
|
||||
app.auto.add(&format!("viewer.motionPath.{}.{i}.{side}", l.id.0), Rect::from_center_size(h, vec2(8.0, 8.0)), "Spatial tangent");
|
||||
hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: h, tangent: Some(out), value: v, p2c });
|
||||
hits.push(KeyHit { layer: l.id, prop, time: *kt, pos: h, tangent: Some(out), value: v, p2c, axes: None });
|
||||
}
|
||||
}
|
||||
let r = Rect::from_center_size(s, vec2(7.0, 7.0));
|
||||
@@ -427,7 +476,7 @@ pub(crate) fn motion_path(
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, kc), StrokeKind::Inside);
|
||||
}
|
||||
app.auto.add(&format!("viewer.motionPath.{}.{i}", l.id.0), r.expand(2.0), "Position keyframe");
|
||||
hits.push(KeyHit { layer: l.id, prop: pos.uid, time: k.time, pos: s, tangent: None, value: v, p2c });
|
||||
hits.push(hit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,7 +593,7 @@ fn point_in_quad(p: Pos2, q: &[Pos2; 4]) -> bool {
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum Drag {
|
||||
/// A motion-path keyframe: its value moves with the pointer (parent space).
|
||||
Key { layer: LayerId, prop: u64, time: Tick, start: [f64; 3], press: [f64; 2], inv: Mat3 },
|
||||
Key { layer: LayerId, prop: u64, time: Tick, start: [f64; 3], press: [f64; 2], inv: Mat3, axes: Option<[u64; 2]> },
|
||||
/// A spatial tangent handle of a motion-path key.
|
||||
KeyTangent { layer: LayerId, prop: u64, time: Tick, out: bool, key: [f64; 3], inv: Mat3 },
|
||||
/// Free transform: 0 move, 1 scale (anchor, axes), 2 rotate about `anchor`.
|
||||
@@ -569,11 +618,17 @@ pub(crate) fn begin_key_drag(app: &mut EffectcraftApp, hits: &[KeyHit], press: P
|
||||
match h.tangent {
|
||||
Some(out) => Some(Drag::KeyTangent { layer: h.layer, prop: h.prop, time: h.time, out, key: h.value, inv }),
|
||||
None => {
|
||||
let k = KeyRef { layer: h.layer, prop: h.prop, time: h.time };
|
||||
if !app.session.state.selected_keys.contains(&k) || shift {
|
||||
let _ = app.session.execute("keys.select", json!({"keys": [{"layer": h.layer.0, "prop": h.prop, "time": h.time.seconds()}], "add": shift}));
|
||||
// Separated dimensions: both X and Y need a key here before the drag moves them.
|
||||
let mut h = *h;
|
||||
for (uid, has) in h.axes.iter_mut().flatten().filter(|(_, has)| !*has) {
|
||||
let _ = app.session.execute("prop.addKey", json!({"layer": h.layer.0, "prop": *uid, "time": h.time.seconds()}));
|
||||
*has = true;
|
||||
}
|
||||
Some(Drag::Key { layer: h.layer, prop: h.prop, time: h.time, start: h.value, press: cpt, inv })
|
||||
if !h.key_refs().iter().all(|k| app.session.state.selected_keys.contains(k)) || shift {
|
||||
let _ = app.session.execute("keys.select", json!({"keys": h.select_entries(), "add": shift}));
|
||||
}
|
||||
let axes = h.axes.map(|a| a.map(|(uid, _)| uid));
|
||||
Some(Drag::Key { layer: h.layer, prop: h.prop, time: h.time, start: h.value, press: cpt, inv, axes })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -639,17 +694,22 @@ pub(crate) fn update(
|
||||
snap: &mut dyn FnMut(&EffectcraftApp, [f64; 2], LayerId) -> [f64; 2],
|
||||
) -> Option<Drag> {
|
||||
match d.clone() {
|
||||
Drag::Key { layer, prop, time, start, press, inv } => {
|
||||
Drag::Key { layer, prop, time, start, press, inv, axes } => {
|
||||
// Snap the key's on-screen position like a layer drag.
|
||||
let start_c = inv.inverse().map(|m| m.apply(gv2(start[0], start[1]))).map(|v| [v.x, v.y]).unwrap_or([start[0], start[1]]);
|
||||
let mut target = [start_c[0] + cpt[0] - press[0], start_c[1] + cpt[1] - press[1]];
|
||||
let s = snap(app, target, layer);
|
||||
target = [target[0] + s[0], target[1] + s[1]];
|
||||
let p = inv.apply(gv2(target[0], target[1]));
|
||||
let v = json!([p.x, p.y, start[2]]);
|
||||
let r = app.session.execute("keys.set", json!({"layer": layer.0, "prop": prop, "time": time.seconds(), "value": v, "merge": merge}));
|
||||
if let Err(e) = r {
|
||||
app.ui.status = e.to_string();
|
||||
let edits = match axes {
|
||||
Some([ux, uy]) => vec![(ux, json!(p.x)), (uy, json!(p.y))],
|
||||
None => vec![(prop, json!([p.x, p.y, start[2]]))],
|
||||
};
|
||||
for (uid, v) in edits {
|
||||
let r = app.session.execute("keys.set", json!({"layer": layer.0, "prop": uid, "time": time.seconds(), "value": v, "merge": merge}));
|
||||
if let Err(e) = r {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -714,6 +774,42 @@ pub(crate) fn update(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn scalar_prop(uid: u64, keys: &[(i64, f64)]) -> Property {
|
||||
use effectcraft_engine::keyframe::{Keyframe, Value};
|
||||
let mut p = Property::new(uid, "positionX", "X Position", Value::Scalar(0.0));
|
||||
p.keys = keys.iter().map(|&(t, v)| Keyframe::new(Tick(t), Value::Scalar(v))).collect();
|
||||
p
|
||||
}
|
||||
|
||||
/// #2242: with separated dimensions the motion path follows the keys of X and Y Position.
|
||||
#[test]
|
||||
fn separated_motion_path_uses_x_and_y_keys() {
|
||||
let x = scalar_prop(1, &[(0, 10.0), (100, 30.0)]);
|
||||
let y = scalar_prop(2, &[(0, 0.0), (50, 20.0), (100, 0.0)]);
|
||||
assert_eq!(separated_key_times(&x, &y), vec![Tick(0), Tick(50), Tick(100)]);
|
||||
let mid = separated_value(&x, &y, Tick(50));
|
||||
assert!((mid[0] - 20.0).abs() < 1e-9 && (mid[1] - 20.0).abs() < 1e-9 && mid[2] == 0.0, "{mid:?}");
|
||||
assert_eq!(separated_value(&x, &y, Tick(100)), [30.0, 0.0, 0.0]);
|
||||
}
|
||||
|
||||
/// #2242: a separated dot selects the X and Y keys at its time, not the nearest X key.
|
||||
#[test]
|
||||
fn separated_key_dot_refers_to_its_axis_keys() {
|
||||
let mut h = KeyHit {
|
||||
layer: LayerId(7),
|
||||
prop: 1,
|
||||
time: Tick(50),
|
||||
pos: Pos2::ZERO,
|
||||
tangent: None,
|
||||
value: [0.0; 3],
|
||||
p2c: Mat3::IDENTITY,
|
||||
axes: Some([(1, false), (2, true)]),
|
||||
};
|
||||
assert_eq!(h.key_refs(), vec![KeyRef { layer: LayerId(7), prop: 2, time: Tick(50) }]);
|
||||
h.axes = None;
|
||||
assert_eq!(h.key_refs(), vec![KeyRef { layer: LayerId(7), prop: 1, time: Tick(50) }]);
|
||||
}
|
||||
|
||||
fn info(vertices: Vec<[f64; 2]>, closed: bool) -> PathInfo {
|
||||
let n = vertices.len();
|
||||
let sp = ShapePath { vertices, in_tangents: vec![[0.0; 2]; n], out_tangents: vec![[0.0; 2]; n], closed, feather: Vec::new() };
|
||||
|
||||
@@ -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!({}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -308,8 +308,8 @@ impl Default for ViewerState {
|
||||
|
||||
impl TimelineState {
|
||||
/// Whether property `uid`'s chain link (Constrain Proportions) is on. One that starts linked
|
||||
/// (Scale, Mask Feather) records a click in `unlinked`; one that starts unlinked (shape Size)
|
||||
/// records it in `linked`.
|
||||
/// (Scale, Mask Feather) records a click in `unlinked`; one that starts unlinked (shape Size,
|
||||
/// Audio Levels) records it in `linked`.
|
||||
pub fn is_linked(&self, uid: u64, linked_by_default: bool) -> bool {
|
||||
if linked_by_default { !self.unlinked.contains(&uid) } else { self.linked.contains(&uid) }
|
||||
}
|
||||
@@ -322,6 +322,16 @@ impl TimelineState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget the chain links when the project was replaced (new or opened): they are keyed by
|
||||
/// property uid, which the next project reuses, so a link turned on must not carry into it.
|
||||
pub fn scope_links(&mut self, project_epoch: u64) {
|
||||
if self.link_epoch != project_epoch {
|
||||
self.link_epoch = project_epoch;
|
||||
self.unlinked.clear();
|
||||
self.linked.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Show `kinds` on `layers` (twirled open; empty: twirled closed) and remember it as the last
|
||||
/// reveal. Other layers keep theirs.
|
||||
pub fn apply_reveal(&mut self, layers: &[u64], kinds: Vec<String>) {
|
||||
@@ -429,10 +439,13 @@ pub struct TimelineState {
|
||||
/// was turned off.
|
||||
#[serde(default)]
|
||||
pub unlinked: BTreeSet<u64>,
|
||||
/// Shape Rectangle and Ellipse Path Size properties whose chain link (off by default) was
|
||||
/// turned on (#2489).
|
||||
/// Shape Rectangle and Ellipse Path Size (#2489) and Audio Levels (#2485) properties whose
|
||||
/// chain link (off by default) was turned on.
|
||||
#[serde(default)]
|
||||
pub linked: BTreeSet<u64>,
|
||||
/// The project (its epoch) `unlinked` and `linked` belong to.
|
||||
#[serde(skip)]
|
||||
pub link_epoch: u64,
|
||||
/// Visible optional columns (column header right-click ▸ Columns): `av`, `keys`, `label`,
|
||||
/// `num`, `comment`, `switches`, `parent`, `in`, `out`, `duration`, `stretch`. The name
|
||||
/// column is always shown; Modes follows `show_modes` (F4).
|
||||
@@ -486,6 +499,7 @@ impl Default for TimelineState {
|
||||
expr_lines: BTreeMap::new(),
|
||||
unlinked: BTreeSet::new(),
|
||||
linked: BTreeSet::new(),
|
||||
link_epoch: 0,
|
||||
columns: default_tl_columns(),
|
||||
source_name: false,
|
||||
scroll_to: None,
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -260,6 +260,7 @@ pub fn twirl(ui: &mut Ui, rect: Rect, open: bool, id: egui::Id, t: &Tokens) -> R
|
||||
vec![c + vec2(-0.8 * k, -1.6 * k), c + vec2(0.8 * k, 0.0), c + vec2(-0.8 * k, 1.6 * k)]
|
||||
};
|
||||
ui.painter().add(egui::Shape::line(pts, Stroke::new(1.4, col)));
|
||||
focus_outline(ui, &resp, t);
|
||||
resp
|
||||
}
|
||||
|
||||
@@ -273,6 +274,7 @@ pub fn icon_toggle(ui: &mut Ui, rect: Rect, icon: Icon, on: bool, t: &Tokens, id
|
||||
if on || resp.hovered() {
|
||||
icons::paint(ui.painter(), rect.shrink(rect.width() * 0.18), icon, if on { t.icon } else { t.text_faint });
|
||||
}
|
||||
focus_outline(ui, &resp, t);
|
||||
resp
|
||||
}
|
||||
|
||||
@@ -297,6 +299,8 @@ pub fn icon_button(ui: &mut Ui, rect: Rect, icon: Icon, active: bool, t: &Tokens
|
||||
t.icon
|
||||
};
|
||||
icons::paint(ui.painter(), rect.shrink(rect.width() * 0.22), icon, col);
|
||||
// An icon has no text: the caller names the button.
|
||||
crate::a11y::role(&resp, egui::accesskit::Role::Button);
|
||||
focus_outline(ui, &resp, t);
|
||||
resp
|
||||
}
|
||||
@@ -341,7 +345,7 @@ fn field(ui: &mut Ui, rect: Rect, text: &mut String, hint: &str, t: &Tokens, sea
|
||||
}
|
||||
let inner = Rect::from_min_max(pos2(rect.min.x + if search { 22.0 } else { 9.0 }, rect.min.y + 1.0), pos2(rect.max.x - 8.0, rect.max.y - 1.0));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner));
|
||||
child.add(
|
||||
let resp = child.add(
|
||||
egui::TextEdit::singleline(text)
|
||||
.hint_text(hint)
|
||||
.frame(egui::Frame::NONE)
|
||||
@@ -350,7 +354,10 @@ fn field(ui: &mut Ui, rect: Rect, text: &mut String, hint: &str, t: &Tokens, sea
|
||||
.min_size(inner.size())
|
||||
.font(Tokens::ui(12.0))
|
||||
.align(egui::Align2::LEFT_CENTER),
|
||||
)
|
||||
);
|
||||
// The hint is the field's visible name (the caller can give it a better one).
|
||||
crate::a11y::name(&resp, hint);
|
||||
resp
|
||||
}
|
||||
|
||||
/// Dropdown-looking control ("Full ▾").
|
||||
@@ -390,6 +397,10 @@ pub fn dropdown_tip(ui: &mut Ui, rect: Rect, text: &str, tip: Option<&str>, t: &
|
||||
};
|
||||
ui.painter().text(pos2(rect.min.x + 7.0, rect.center().y), Align2::LEFT_CENTER, shown, Tokens::ui(11.5), t.text);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.max.x - 9.0, rect.center().y), vec2(8.0, 8.0)), Icon::ChevronDown, t.text_dim);
|
||||
// A drop-down showing `text`; the caller names what it sets.
|
||||
crate::a11y::role(&resp, egui::accesskit::Role::ComboBox);
|
||||
crate::a11y::value(&resp, text);
|
||||
focus_outline(ui, &resp, t);
|
||||
if let Some(tip) = tip {
|
||||
return resp.on_hover_text(tip.to_string());
|
||||
}
|
||||
@@ -412,12 +423,178 @@ pub fn text_button(ui: &mut Ui, rect: Rect, text: &str, primary: bool, t: &Token
|
||||
};
|
||||
ui.painter().rect_filled(rect, rect.height() / 2.0, bg);
|
||||
ui.painter().text(rect.center(), Align2::CENTER_CENTER, text, Tokens::medium(12.0), if primary { Color32::WHITE } else { t.text });
|
||||
crate::a11y::role_name(&resp, egui::accesskit::Role::Button, text);
|
||||
focus_outline(ui, &resp, t);
|
||||
resp
|
||||
}
|
||||
|
||||
/// Checkbox in the AE look (small square).
|
||||
/// A primary-button gesture shared by custom and labelled checkboxes in this context.
|
||||
#[derive(Clone, Default)]
|
||||
struct CheckboxDrag {
|
||||
press_frame: u64,
|
||||
target: Option<bool>,
|
||||
visited: std::collections::HashSet<egui::Id>,
|
||||
/// Checkboxes the pointer crossed in the frame of the press, before the checkbox it pressed on
|
||||
/// had been drawn (so the value to set was not known): they take it in the next frame.
|
||||
pending: std::collections::HashSet<egui::Id>,
|
||||
}
|
||||
|
||||
/// A primary-button event of the frame, in the order it happened.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Step {
|
||||
Press,
|
||||
Move,
|
||||
Release,
|
||||
}
|
||||
|
||||
/// The frame's primary-button events in order, each with its position. egui hit-tests the whole
|
||||
/// frame at the last pointer position, so a press followed by a move in one frame would otherwise
|
||||
/// be taken for a press where the pointer ended.
|
||||
fn pointer_steps(ui: &Ui) -> Vec<(Step, egui::Pos2)> {
|
||||
ui.input(|i| {
|
||||
i.events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
egui::Event::PointerMoved(pos) => Some((Step::Move, *pos)),
|
||||
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, .. } => {
|
||||
Some((if *pressed { Step::Press } else { Step::Release }, *pos))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the requested value. Mixed checkboxes (`None`) and disabled controls are
|
||||
/// skipped. Each checkbox is visited once, so crossing it again cannot toggle it back. The
|
||||
/// checkbox a press lands on, and the ones the pointer passes over after it, are found from the
|
||||
/// frame's ordered pointer events, not from where the pointer ended.
|
||||
fn drag_toggle(ui: &Ui, response: &Response, value: Option<bool>) -> Option<bool> {
|
||||
let (pressed, down, released) = ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_down(), i.pointer.primary_released()));
|
||||
let frame = ui.ctx().cumulative_frame_nr();
|
||||
// clicked() reads Context input; do not call it while Context data is locked.
|
||||
let clicked = response.clicked();
|
||||
// Whether the pointer at `pos` is over this checkbox itself (not a window above it).
|
||||
let over = |pos: egui::Pos2| response.rect.contains(pos) && ui.clip_rect().contains(pos) && ui.ctx().layer_id_at(pos) == Some(response.layer_id);
|
||||
// Resolved before the data lock below: layer_id_at reads the Context.
|
||||
let steps: Vec<(Step, bool)> = pointer_steps(ui).into_iter().map(|(step, pos)| (step, over(pos))).collect();
|
||||
ui.data_mut(|data| {
|
||||
let drag = data.get_temp_mut_or_default::<CheckboxDrag>(egui::Id::unique("checkbox-drag"));
|
||||
// A release in the press frame leaves crossings pending: keep them for the next frame.
|
||||
let flushing = !drag.pending.is_empty() && drag.target.is_some() && frame <= drag.press_frame.saturating_add(1);
|
||||
if (!down && !released && !flushing) || (pressed && drag.press_frame != frame) {
|
||||
*drag = CheckboxDrag { press_frame: frame, ..Default::default() };
|
||||
}
|
||||
let on = value?;
|
||||
if !response.enabled() {
|
||||
return None;
|
||||
}
|
||||
// Crossed in the frame of the press before the value was known: take it now.
|
||||
if drag.pending.remove(&response.id)
|
||||
&& let Some(target) = drag.target
|
||||
&& drag.visited.insert(response.id)
|
||||
&& on != target
|
||||
{
|
||||
return Some(target);
|
||||
}
|
||||
// Whether the button is held, tracked in event order: a move after a release in the same frame is no drag.
|
||||
// Before the first button event it is held exactly when that event is a release (or, with none, when it is down).
|
||||
let mut held = match steps.iter().find(|(step, _)| *step != Step::Move) {
|
||||
Some((step, _)) => *step == Step::Release,
|
||||
None => down,
|
||||
};
|
||||
let mut press_seen = false;
|
||||
for (step, over) in steps {
|
||||
match step {
|
||||
Step::Press => {
|
||||
press_seen = true;
|
||||
held = true;
|
||||
if drag.target.is_none() && over {
|
||||
drag.target = Some(!on);
|
||||
drag.visited.insert(response.id);
|
||||
return Some(!on);
|
||||
}
|
||||
}
|
||||
Step::Release => held = false,
|
||||
Step::Move if held => match drag.target {
|
||||
Some(target) if over => {
|
||||
if drag.visited.insert(response.id) && on != target {
|
||||
return Some(target);
|
||||
}
|
||||
}
|
||||
None if press_seen && over => {
|
||||
drag.pending.insert(response.id);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// The pointer held still over this checkbox in a later frame of the gesture.
|
||||
if down
|
||||
&& !pressed
|
||||
&& response.contains_pointer()
|
||||
&& let Some(target) = drag.target
|
||||
{
|
||||
if drag.visited.insert(response.id) && on != target {
|
||||
return Some(target);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
// The source already changed on press. Suppress its ordinary release click.
|
||||
if released && drag.target.is_some() {
|
||||
return None;
|
||||
}
|
||||
clicked.then_some(!on)
|
||||
})
|
||||
}
|
||||
|
||||
/// A labelled checkbox with the same drag behavior as the custom panel checkbox.
|
||||
/// Use through `ui.add`, `ui.put`, or `ui.add_enabled` like `egui::Checkbox`.
|
||||
pub struct DragCheckbox<'a> {
|
||||
value: &'a mut bool,
|
||||
text: egui::WidgetText,
|
||||
}
|
||||
|
||||
impl<'a> DragCheckbox<'a> {
|
||||
pub fn new(value: &'a mut bool, text: impl Into<egui::WidgetText>) -> Self {
|
||||
Self { value, text: text.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl egui::Widget for DragCheckbox<'_> {
|
||||
fn ui(self, ui: &mut Ui) -> Response {
|
||||
let before = *self.value;
|
||||
let mut response = ui.add(egui::Checkbox::new(self.value, self.text));
|
||||
// egui changes on release; our shared gesture changes on press and entry.
|
||||
*self.value = before;
|
||||
response.flags.remove(egui::response::Flags::CHANGED);
|
||||
if response.clicked() {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
if let Some(value) = drag_toggle(ui, &response, Some(before)) {
|
||||
*self.value = value;
|
||||
response.mark_changed();
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
/// A labelled checkbox that allocates its own rectangle.
|
||||
pub fn labelled_checkbox(ui: &mut Ui, value: &mut bool, text: impl Into<egui::WidgetText>) -> Response {
|
||||
ui.add(DragCheckbox::new(value, text))
|
||||
}
|
||||
|
||||
/// Checkbox in the AE look (small square). `changed()` requests a toggle, including
|
||||
/// keyboard activation and entry during a drag begun on another checkbox.
|
||||
pub fn checkbox(ui: &mut Ui, rect: Rect, on: bool, t: &Tokens, id: egui::Id) -> Response {
|
||||
let resp = ui.interact(rect, id, Sense::click());
|
||||
let mut resp = ui.interact(rect, id, Sense::click());
|
||||
let next = drag_toggle(ui, &resp, Some(on));
|
||||
if next.is_some() {
|
||||
resp.mark_changed();
|
||||
}
|
||||
let on = next.unwrap_or(on);
|
||||
let b = Rect::from_center_size(rect.center(), vec2(12.0, 12.0));
|
||||
ui.painter().rect_filled(b, 2.0, if on { t.accent } else { t.field_bg });
|
||||
ui.painter().rect_stroke(b, 2.0, Stroke::new(1.0, if on { t.accent } else { t.field_border }), StrokeKind::Inside);
|
||||
@@ -425,6 +602,7 @@ pub fn checkbox(ui: &mut Ui, rect: Rect, on: bool, t: &Tokens, id: egui::Id) ->
|
||||
let p = |x: f32, y: f32| pos2(b.min.x + x * b.width(), b.min.y + y * b.height());
|
||||
ui.painter().line(vec![p(0.2, 0.52), p(0.42, 0.74), p(0.8, 0.28)], Stroke::new(1.6, Color32::WHITE));
|
||||
}
|
||||
focus_outline(ui, &resp, t);
|
||||
resp
|
||||
}
|
||||
|
||||
@@ -444,7 +622,9 @@ pub fn scroll_bar(ui: &mut Ui, auto: &mut Registry, key: &str, track: Rect, scro
|
||||
let thumb_len = (len * len / (len + max.max(0.0)).max(1.0)).max(16.0).min(len).max(0.0);
|
||||
let travel = (len - thumb_len).max(1.0);
|
||||
let thumb_at = |s: f32| track.min[a] + (len - thumb_len) * if max > 0.0 { (s / max).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let resp = ui.interact(track.expand2(if vertical { vec2(3.0, 0.0) } else { vec2(0.0, 3.0) }), egui::Id::unique(key), Sense::drag());
|
||||
// Dragged with the mouse only: a Tab stop that no key could move would be a trap. The panels
|
||||
// scroll with their own keys (the arrows, Page Up / Page Down) and the wheel.
|
||||
let resp = ui.interact(track.expand2(if vertical { vec2(3.0, 0.0) } else { vec2(0.0, 3.0) }), egui::Id::unique(key), Sense::DRAG);
|
||||
let pressed = resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.any_pressed());
|
||||
let mut s = scroll;
|
||||
if let Some(p) = resp.interact_pointer_pos().filter(|p| pressed && !(thumb_at(scroll)..=thumb_at(scroll) + thumb_len).contains(&p[a])) {
|
||||
@@ -488,6 +668,22 @@ impl PanelScroll {
|
||||
Self { id, area, offset: offset.clamp(0.0, max.max(0.0)) }
|
||||
}
|
||||
|
||||
/// Scroll the panel `id` (a [`PanelScroll`] kept under that id) so the content from `top` to
|
||||
/// `bottom` is in view of a `view` tall list, from the next pass.
|
||||
pub fn reveal(ctx: &egui::Context, id: egui::Id, top: f32, bottom: f32, view: f32) {
|
||||
let (offset, max): (f32, f32) = ctx.data(|d| d.get_temp(id)).unwrap_or_default();
|
||||
let wanted = if top < offset {
|
||||
top
|
||||
} else if bottom > offset + view {
|
||||
bottom - view
|
||||
} else {
|
||||
offset
|
||||
}
|
||||
.max(0.0);
|
||||
// (`max` is last frame's overflow; the next `end` measures it again.)
|
||||
ctx.data_mut(|d| d.insert_temp(id, (wanted, max.max(wanted))));
|
||||
}
|
||||
|
||||
/// The rows were `content` points tall: keep the offset, with the scroll bar registered for
|
||||
/// automation as `key`.
|
||||
pub fn end(self, ui: &mut Ui, auto: &mut Registry, key: &str, content: f32, t: &Tokens) {
|
||||
@@ -572,6 +768,30 @@ pub fn label_popup(
|
||||
})
|
||||
}
|
||||
|
||||
/// This pass's input has no mouse press or release: whatever just happened (a control activated,
|
||||
/// a list opened, an entry chosen) came from the keyboard or an assistive technology.
|
||||
pub fn by_keyboard(ui: &Ui) -> bool {
|
||||
!ui.input(|i| i.pointer.any_pressed() || i.pointer.any_released())
|
||||
}
|
||||
|
||||
/// Put the keyboard on `entry` of the popup list `id` the pass the list opens, so Up / Down and
|
||||
/// Enter work it and Escape closes it. (The pass count tells a fresh opening from a list that has
|
||||
/// been open for some passes.)
|
||||
pub fn focus_when_opened(ui: &Ui, id: egui::Id, entry: &Response) {
|
||||
let key = id.with("focus-pass");
|
||||
let pass = ui.ctx().cumulative_pass_nr();
|
||||
let previous = ui.data_mut(|d| {
|
||||
let previous = d.get_temp::<u64>(key);
|
||||
d.insert_temp(key, pass);
|
||||
previous
|
||||
});
|
||||
// A list opened with the mouse keeps the keyboard where it was: a widget with the focus
|
||||
// pauses the application's shortcuts (Space, Ctrl+Z) until the focus is lost.
|
||||
if previous.is_none_or(|p| p.saturating_add(1) < pass) && by_keyboard(ui) {
|
||||
entry.request_focus();
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a popup menu anchored at `pos` with string options; returns the chosen index.
|
||||
pub fn popup_menu(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, options: &[String], current: Option<usize>) -> Option<usize> {
|
||||
if !popup_is_open(ui, id) {
|
||||
@@ -581,11 +801,20 @@ pub fn popup_menu(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, options: &[String]
|
||||
popup_list(ui, id, pos, Rect::NOTHING, options.len().saturating_sub(seps), seps, |ui| {
|
||||
ui.set_min_width(160.0);
|
||||
let mut chosen = None;
|
||||
let mut first = true;
|
||||
for (i, o) in options.iter().enumerate() {
|
||||
if o == "-" {
|
||||
ui.separator();
|
||||
} else if ui.selectable_label(current == Some(i), o).clicked() {
|
||||
chosen = Some(i);
|
||||
} else {
|
||||
let r = ui.selectable_label(current == Some(i), o);
|
||||
// The keyboard starts on the current entry (the first one, with none current).
|
||||
if current == Some(i) || (current.is_none() && first) {
|
||||
focus_when_opened(ui, id, &r);
|
||||
}
|
||||
first = false;
|
||||
if r.clicked() {
|
||||
chosen = Some(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
chosen
|
||||
@@ -820,10 +1049,208 @@ pub(crate) fn constrained(c: &[f64], d: usize, v: f64) -> Vec<f64> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Linked values after component `d` of `c` became `v`: the others move by the same amount, so
|
||||
/// linked Audio Levels keep their stereo balance in dB (#2485).
|
||||
pub(crate) fn shifted(c: &[f64], d: usize, v: f64) -> Vec<f64> {
|
||||
let Some(&old) = c.get(d) else { return c.to_vec() };
|
||||
c.iter().enumerate().map(|(e, &x)| if e == d { v } else { x + (v - old) }).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn checkbox_frame(ctx: &egui::Context, values: &mut [Option<bool>; 5], events: Vec<egui::Event>) {
|
||||
let t = Tokens::for_kind(Default::default());
|
||||
let input = egui::RawInput { events, ..Default::default() };
|
||||
let mut output = ctx.run_ui(input, |ui| {
|
||||
for (index, value) in values.iter_mut().enumerate() {
|
||||
let rect = Rect::from_min_size(pos2(20.0, 20.0 + index as f32 * 30.0), vec2(120.0, 20.0));
|
||||
ui.add_enabled_ui(index != 3, |ui| {
|
||||
if index == 4 {
|
||||
if let Some(on) = value {
|
||||
ui.put(rect, DragCheckbox::new(on, crate::i18n::tr("Grid")));
|
||||
}
|
||||
} else if let Some(on) = value {
|
||||
if checkbox(ui, rect, *on, &t, egui::Id::unique(index)).changed() {
|
||||
*on = !*on;
|
||||
}
|
||||
} else {
|
||||
let response = ui.interact(rect, egui::Id::unique(index), Sense::click());
|
||||
assert_eq!(drag_toggle(ui, &response, None), None);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn checkbox_pointer(y: f32, pressed: Option<bool>) -> Vec<egui::Event> {
|
||||
let pos = pos2(28.0, y);
|
||||
let mut events = vec![egui::Event::PointerMoved(pos)];
|
||||
if let Some(pressed) = pressed {
|
||||
events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkbox_drag_sets_one_state_skips_mixed_and_disabled_and_ends_on_release() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut values = [Some(false), Some(true), None, Some(false), Some(false)];
|
||||
checkbox_frame(&ctx, &mut values, vec![]);
|
||||
checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, Some(true)));
|
||||
assert_eq!(values[0], Some(true));
|
||||
// An already-matching neighbour must not invert, even after revisiting.
|
||||
for y in [58.0, 88.0, 118.0, 148.0, 58.0, 28.0] {
|
||||
checkbox_frame(&ctx, &mut values, checkbox_pointer(y, None));
|
||||
}
|
||||
assert_eq!(values, [Some(true), Some(true), None, Some(false), Some(true)]);
|
||||
checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, Some(false)));
|
||||
assert_eq!(values[0], Some(true), "release must not toggle the source again");
|
||||
// Begin on the labelled wrapper and drag back into custom checkboxes.
|
||||
checkbox_frame(&ctx, &mut values, checkbox_pointer(148.0, Some(true)));
|
||||
assert_eq!(values[4], Some(false));
|
||||
for y in [58.0, 28.0] {
|
||||
checkbox_frame(&ctx, &mut values, checkbox_pointer(y, None));
|
||||
}
|
||||
checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, Some(false)));
|
||||
assert_eq!(values, [Some(false), Some(false), None, Some(false), Some(false)]);
|
||||
checkbox_frame(&ctx, &mut values, checkbox_pointer(58.0, None));
|
||||
assert_eq!(values[1], Some(false), "hover after release must not paint");
|
||||
}
|
||||
|
||||
fn button(y: f32, pressed: bool) -> Vec<egui::Event> {
|
||||
let pos = pos2(28.0, y);
|
||||
vec![egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }]
|
||||
}
|
||||
|
||||
fn moved(y: f32) -> egui::Event {
|
||||
egui::Event::PointerMoved(pos2(28.0, y))
|
||||
}
|
||||
|
||||
/// A press and the moves after it can arrive in one frame (a quick flick): the press belongs to
|
||||
/// the checkbox it landed on, not to the one the pointer ended over, and the ones passed over
|
||||
/// follow the pressed one's new state, whether they are drawn before it or after it.
|
||||
#[test]
|
||||
fn a_press_and_a_move_in_one_frame_start_on_the_checkbox_pressed() {
|
||||
// Rows are 30 apart, from y = 28; index 3 is disabled.
|
||||
let (a, b) = (28.0, 58.0);
|
||||
// Pressed on the first, moved to the second (drawn after it).
|
||||
let ctx = egui::Context::default();
|
||||
let mut values = [Some(false); 5];
|
||||
values[2] = None;
|
||||
checkbox_frame(&ctx, &mut values, vec![]);
|
||||
let mut events = vec![moved(a)];
|
||||
events.extend(button(a, true));
|
||||
events.push(moved(b));
|
||||
checkbox_frame(&ctx, &mut values, events);
|
||||
assert_eq!((values[0], values[1]), (Some(true), Some(true)), "the pressed one is the source, the one passed over follows");
|
||||
let mut events = vec![moved(b)];
|
||||
events.extend(button(b, false));
|
||||
checkbox_frame(&ctx, &mut values, events);
|
||||
assert_eq!((values[0], values[1]), (Some(true), Some(true)), "release changes nothing");
|
||||
// Pressed on the second, moved to the first (drawn before it): the first takes the state in
|
||||
// the next frame, while the button is still down.
|
||||
let ctx = egui::Context::default();
|
||||
let mut values = [Some(false); 5];
|
||||
values[2] = None;
|
||||
checkbox_frame(&ctx, &mut values, vec![]);
|
||||
let mut events = vec![moved(b)];
|
||||
events.extend(button(b, true));
|
||||
events.push(moved(a));
|
||||
checkbox_frame(&ctx, &mut values, events);
|
||||
assert_eq!(values[1], Some(true), "the pressed one is the source");
|
||||
checkbox_frame(&ctx, &mut values, vec![]);
|
||||
assert_eq!(values[0], Some(true), "the one passed over takes the state");
|
||||
let mut events = vec![moved(a)];
|
||||
events.extend(button(a, false));
|
||||
checkbox_frame(&ctx, &mut values, events);
|
||||
assert_eq!((values[0], values[1]), (Some(true), Some(true)));
|
||||
// Pressed outside every checkbox, then moved onto one in the same frame: nothing starts.
|
||||
let ctx = egui::Context::default();
|
||||
let mut values = [Some(false); 5];
|
||||
values[2] = None;
|
||||
checkbox_frame(&ctx, &mut values, vec![]);
|
||||
let mut events = vec![moved(200.0)];
|
||||
events.extend(button(200.0, true));
|
||||
events.push(moved(a));
|
||||
checkbox_frame(&ctx, &mut values, events);
|
||||
checkbox_frame(&ctx, &mut values, vec![]);
|
||||
assert_eq!(values, [Some(false), Some(false), None, Some(false), Some(false)], "a press outside starts no gesture");
|
||||
// Pressed on the second, moved to the first (drawn before it) and released, all in one
|
||||
// frame: the first still takes the state in the next frame.
|
||||
let ctx = egui::Context::default();
|
||||
let mut values = [Some(false); 5];
|
||||
values[2] = None;
|
||||
checkbox_frame(&ctx, &mut values, vec![]);
|
||||
let mut events = vec![moved(b)];
|
||||
events.extend(button(b, true));
|
||||
events.push(moved(a));
|
||||
events.extend(button(a, false));
|
||||
checkbox_frame(&ctx, &mut values, events);
|
||||
checkbox_frame(&ctx, &mut values, vec![]);
|
||||
assert_eq!((values[0], values[1]), (Some(true), Some(true)), "a crossing before the release still takes the state");
|
||||
}
|
||||
|
||||
/// A move that arrives after the release, in the frame of the click, is the pointer travelling without the button:
|
||||
/// it must not carry the gesture onto the checkbox it ends over.
|
||||
#[test]
|
||||
fn a_move_after_the_release_in_the_same_frame_paints_nothing() {
|
||||
let (a, b) = (28.0, 58.0);
|
||||
let ctx = egui::Context::default();
|
||||
let mut values = [Some(false); 5];
|
||||
values[2] = None;
|
||||
checkbox_frame(&ctx, &mut values, vec![]);
|
||||
let mut events = vec![moved(a)];
|
||||
events.extend(button(a, true));
|
||||
events.extend(button(a, false));
|
||||
events.push(moved(b));
|
||||
checkbox_frame(&ctx, &mut values, events);
|
||||
checkbox_frame(&ctx, &mut values, vec![]);
|
||||
assert_eq!((values[0], values[1]), (Some(true), Some(false)), "the clicked one changes, the one moved onto afterwards does not");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkbox_click_and_keyboard_toggle_once_and_drag_must_start_on_a_checkbox() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut values = [Some(false), Some(false), None, Some(false), Some(false)];
|
||||
checkbox_frame(&ctx, &mut values, vec![]);
|
||||
// Pressing outside the checkboxes must not start a gesture on entry.
|
||||
checkbox_frame(&ctx, &mut values, checkbox_pointer(200.0, Some(true)));
|
||||
checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, None));
|
||||
checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, Some(false)));
|
||||
assert_eq!(values[0], Some(false));
|
||||
for source_y in [88.0, 118.0] {
|
||||
checkbox_frame(&ctx, &mut values, checkbox_pointer(source_y, Some(true)));
|
||||
checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, None));
|
||||
checkbox_frame(&ctx, &mut values, checkbox_pointer(28.0, Some(false)));
|
||||
assert_eq!(values[0], Some(false), "mixed and disabled sources cannot begin a drag");
|
||||
}
|
||||
for y in [28.0, 148.0] {
|
||||
checkbox_frame(&ctx, &mut values, checkbox_pointer(y, Some(true)));
|
||||
checkbox_frame(&ctx, &mut values, checkbox_pointer(y, Some(false)));
|
||||
}
|
||||
assert_eq!(values[0], Some(true));
|
||||
assert_eq!(values[4], Some(true));
|
||||
ctx.memory_mut(|m| m.request_focus(egui::Id::unique(0usize)));
|
||||
checkbox_frame(
|
||||
&ctx,
|
||||
&mut values,
|
||||
vec![egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }],
|
||||
);
|
||||
assert_eq!(values[0], Some(false), "keyboard activation still works");
|
||||
// Fast clicks may deliver press and release together in one input frame.
|
||||
// This also exercises clicked() without an active drag (outside the data lock).
|
||||
for (index, y) in [(0, 28.0), (4, 148.0)] {
|
||||
let before = values[index];
|
||||
let mut events = checkbox_pointer(y, Some(true));
|
||||
events.extend(checkbox_pointer(y, Some(false)));
|
||||
checkbox_frame(&ctx, &mut values, events);
|
||||
assert_eq!(values[index], before.map(|on| !on));
|
||||
}
|
||||
}
|
||||
|
||||
/// #2097: the drag speed comes from the slider range, not a much wider hard range.
|
||||
#[test]
|
||||
fn slider_scrub_speed_uses_the_slider_range() {
|
||||
|
||||
@@ -15,6 +15,7 @@ mod ui_blank_context_menus;
|
||||
mod ui_camera_tracker;
|
||||
mod ui_character_eyedropper;
|
||||
mod ui_character_stroke_order;
|
||||
mod ui_checkbox_drag;
|
||||
mod ui_color_hex;
|
||||
mod ui_comp_presets;
|
||||
mod ui_control_modifiers;
|
||||
@@ -68,6 +69,7 @@ mod ui_render_queue;
|
||||
mod ui_script_console;
|
||||
mod ui_scriptui;
|
||||
mod ui_scrolling;
|
||||
mod ui_self_drop;
|
||||
mod ui_settings_info;
|
||||
mod ui_tabs;
|
||||
mod ui_tail;
|
||||
@@ -75,7 +77,9 @@ mod ui_text_edit;
|
||||
mod ui_text_range_handles;
|
||||
mod ui_theme_favorites;
|
||||
mod ui_time_zoom_controls;
|
||||
mod ui_timeline_audio_levels;
|
||||
mod ui_timeline_effect_label;
|
||||
mod ui_timeline_group_select;
|
||||
mod ui_timeline_pan;
|
||||
mod ui_timeline_shapes;
|
||||
mod ui_timeline_switches;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
//! #1190: the shared drag gesture also reaches real dialog controls.
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_ui_egui::{EffectcraftApp, menus};
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
|
||||
use egui_kittest::{Harness, kittest::Queryable as _};
|
||||
use serde_json::json;
|
||||
|
||||
fn pointer(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, pressed: Option<bool>) {
|
||||
h.event(Event::PointerMoved(at));
|
||||
h.step();
|
||||
if let Some(pressed) = pressed {
|
||||
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_options_drag_checks_and_unchecks_neighbours() {
|
||||
let mut session = Session::default();
|
||||
session.prefs.disk.disk_cache_enabled = false;
|
||||
session.execute("comp.new", json!({"width":32,"height":32,"duration":1})).unwrap();
|
||||
let mut h = Harness::builder().with_size(vec2(1200.0, 900.0)).build_eframe(|_| EffectcraftApp::new(session));
|
||||
h.state_mut().ui.viewer.rulers = false;
|
||||
h.state_mut().ui.viewer.guides = true;
|
||||
h.state_mut().ui.viewer.grid = false;
|
||||
h.run_steps(3);
|
||||
let ctx = h.ctx.clone();
|
||||
menus::invoke(h.state_mut(), &ctx, "view.options", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
let rulers = h.get_by_label("Rulers").rect().center();
|
||||
let guides = h.get_by_label("Guides").rect().center();
|
||||
let grid = h.get_by_label("Grid").rect().center();
|
||||
pointer(&mut h, rulers, Some(true));
|
||||
assert!(h.state().ui.viewer.rulers);
|
||||
pointer(&mut h, guides, None);
|
||||
assert!(h.state().ui.viewer.guides, "an already-checked neighbour stays checked");
|
||||
pointer(&mut h, grid, None);
|
||||
assert!(h.state().ui.viewer.grid);
|
||||
pointer(&mut h, rulers, None);
|
||||
pointer(&mut h, rulers, Some(false));
|
||||
assert!(h.state().ui.viewer.rulers, "revisiting and releasing does not undo the change");
|
||||
if let Ok(path) = std::env::var("CHECKBOX_REVIEW_PNG") {
|
||||
h.render().unwrap().save(path).unwrap();
|
||||
}
|
||||
pointer(&mut h, grid, Some(true));
|
||||
pointer(&mut h, guides, None);
|
||||
pointer(&mut h, rulers, None);
|
||||
pointer(&mut h, rulers, Some(false));
|
||||
let viewer = &h.state().ui.viewer;
|
||||
assert!(!viewer.rulers && !viewer.guides && !viewer.grid);
|
||||
pointer(&mut h, guides, None);
|
||||
assert!(!h.state().ui.viewer.guides, "hover after release does not change a checkbox");
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -77,3 +77,75 @@ fn open_graph_toggle_twice(h: &mut Harness<'_, EffectcraftApp>) {
|
||||
assert!(!h.state().ui.timeline.graph_editor);
|
||||
open_graph(h);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_graph_keys_preserve_the_existing_selection_and_keyframes() {
|
||||
use egui::{Event, Modifiers, PointerButton, pos2, vec2};
|
||||
for (button, modifiers, drag) in [
|
||||
(PointerButton::Primary, Modifiers::NONE, false),
|
||||
(PointerButton::Primary, Modifiers::SHIFT, false),
|
||||
(PointerButton::Primary, Modifiers::COMMAND, false),
|
||||
(PointerButton::Secondary, Modifiers::NONE, false),
|
||||
(PointerButton::Primary, Modifiers::NONE, true),
|
||||
] {
|
||||
let (mut h, layer, uid) = harness(true);
|
||||
let s = &mut h.state_mut().session;
|
||||
let locked = s.execute("layer.newSolid", json!({"name": "Locked", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
for (time, value) in [(9.0, 100.0), (11.0, 200.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": locked, "path": "transform/rotation", "time": time, "value": value})).unwrap();
|
||||
}
|
||||
let locked_id = effectcraft_engine::project::LayerId(locked);
|
||||
let locked_uid = s.active_comp().unwrap().layer(locked_id).unwrap().props.prop("transform/rotation").unwrap().uid;
|
||||
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "locked", "value": true})).unwrap();
|
||||
s.execute("keys.select", json!({"keys": [{"layer": layer, "prop": uid, "time": 12.0}]})).unwrap();
|
||||
s.state.selected_props = vec![(effectcraft_engine::project::LayerId(layer), uid), (locked_id, locked_uid)];
|
||||
let selection = s.state.selected_keys.clone();
|
||||
let before = s.project.clone();
|
||||
open_graph(&mut h);
|
||||
h.state_mut().ui.timeline.graph_auto_zoom = false;
|
||||
h.state_mut().ui.timeline.graph_range = Some((0.0, 800.0));
|
||||
h.run_steps(3);
|
||||
let [x, y, w, height] = h.state().auto.find(&format!("timeline.graph.key.{locked_uid}.0.0")).expect("locked key drawn").rect;
|
||||
let point = pos2(x + w / 2.0, y + height / 2.0);
|
||||
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
|
||||
h.input_mut().events.push(Event::PointerMoved(point));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: point, button, pressed: true, modifiers });
|
||||
h.step();
|
||||
let end = if drag { point + vec2(30.0, -20.0) } else { point };
|
||||
h.input_mut().events.push(Event::PointerMoved(end));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: end, button, pressed: false, modifiers });
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.state.selected_keys, selection, "{button:?}, {modifiers:?}, drag={drag}");
|
||||
assert_eq!(
|
||||
serde_json::to_value(&h.state().session.project).unwrap(),
|
||||
serde_json::to_value(&before).unwrap(),
|
||||
"locked gestures must not edit another key"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unlocked_graph_keys_menu_edits_the_clicked_key() {
|
||||
use egui::{Event, Modifiers, PointerButton, pos2};
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let (mut h, layer, uid) = harness(true);
|
||||
h.state_mut().session.execute("keys.select", json!({"keys": [{"layer": layer, "prop": uid, "time": 12.0}]})).unwrap();
|
||||
open_graph(&mut h);
|
||||
let [x, y, w, height] = h.state().auto.find(&format!("timeline.graph.key.{uid}.0.0")).unwrap().rect;
|
||||
let at = pos2(x + w / 2.0, y + height / 2.0);
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::PointerMoved(at));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
}
|
||||
h.run_steps(3);
|
||||
let selected = &h.state().session.state.selected_keys;
|
||||
assert_eq!(selected.len(), 1);
|
||||
assert_eq!(selected[0].time.seconds(), 8.0);
|
||||
h.get_by_label("Easy Ease").click();
|
||||
h.run_steps(3);
|
||||
let prop = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.find(uid).unwrap();
|
||||
assert_eq!(prop.keys[0].out_interp, effectcraft_engine::keyframe::Interp::Bezier);
|
||||
assert_eq!(prop.keys[1].out_interp, effectcraft_engine::keyframe::Interp::Linear);
|
||||
}
|
||||
|
||||
@@ -53,8 +53,7 @@ fn entry_for(h: &Harness<'_, EffectcraftApp>, layer: u64) -> String {
|
||||
format!("{}. {}", i + 1, comp.layers[i].name)
|
||||
}
|
||||
|
||||
/// Puts the layer-valued `prop` of `layer` back to `value` with a direct `prop.set` (no merge key,
|
||||
/// so the pick below records its own step instead of folding into a picker write), picks `none`
|
||||
/// Puts the layer-valued `prop` of `layer` back to `value` with a direct `prop.set`, picks `none`
|
||||
/// from the drop-down at `value_id`, and checks the clear takes one undo step that restores `value`.
|
||||
#[track_caller]
|
||||
fn none_pick_clears(h: &mut Harness<'_, EffectcraftApp>, value_id: &str, none: &str, layer: u64, prop: u64, value: u64) {
|
||||
@@ -73,6 +72,24 @@ fn none_pick_clears(h: &mut Harness<'_, EffectcraftApp>, value_id: &str, none: &
|
||||
assert_eq!(layer_value(h, layer, prop), Some(value), "undo restores the selected layer");
|
||||
}
|
||||
|
||||
/// #2152: two picks in a row from the drop-down at `value_id` (`first`, the layer `value`, then
|
||||
/// `none`) are two undo steps: one Undo goes back to the first pick, not past it.
|
||||
#[track_caller]
|
||||
fn picks_are_separate_steps(h: &mut Harness<'_, EffectcraftApp>, value_id: &str, first: &str, none: &str, layer: u64, prop: u64, value: u64) {
|
||||
let steps = h.state().session.history.undo.len();
|
||||
for entry in [first, none] {
|
||||
let at = rect_of(h, value_id).center();
|
||||
click(h, at);
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Button, entry).click();
|
||||
h.run_steps(3);
|
||||
}
|
||||
assert_eq!(layer_value(h, layer, prop), None);
|
||||
assert_eq!(h.state().session.history.undo.len(), steps + 2, "each pick is its own undo step");
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(layer_value(h, layer, prop), Some(value), "one undo goes back to the first pick");
|
||||
}
|
||||
|
||||
/// A light's Source (a layer-valued Light Options property) picks a comp layer from the
|
||||
/// Timeline and undo restores None.
|
||||
#[test]
|
||||
@@ -138,6 +155,9 @@ fn timeline_source_drop_down_picks_the_layer_and_undo_restores_none() {
|
||||
assert_eq!(layer_value(&h, light, source), None, "undo restores None");
|
||||
|
||||
none_pick_clears(&mut h, &value_id, "None", light, source, sky);
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
picks_are_separate_steps(&mut h, &value_id, &entry, "None", light, source, sky);
|
||||
}
|
||||
|
||||
/// The shared widget is the one Effect Controls already used: a Displacement Map's layer
|
||||
@@ -176,6 +196,9 @@ fn effect_controls_layer_param_picks_through_the_shared_widget() {
|
||||
assert_eq!(layer_value(&h, base, prop), None, "undo restores None");
|
||||
|
||||
none_pick_clears(&mut h, &value_id, "None", base, prop, map);
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
picks_are_separate_steps(&mut h, &value_id, &entry, "None", base, prop, map);
|
||||
}
|
||||
|
||||
/// The visual record (the `ui_3d` ignored-snapshot convention): the Source drop-down open over
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
//! #2589: a Project selection that contains the composition being dropped into must not fail the
|
||||
//! whole drop. Rendering writes a file named after the composition, so the Project panel holds
|
||||
//! both and selects both; the composition itself is not a layer it can hold.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use egui::{Event, Modifiers, Pos2, Rect, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
/// Compositions "Main" (active, 4 s at 30 fps, one solid) and "Clip".
|
||||
fn harness() -> (Harness<'static, EffectcraftApp>, u64, u64) {
|
||||
let mut s = Session::default();
|
||||
let clip = s.execute("comp.new", json!({"name": "Clip", "width": 160, "height": 90, "frameRate": 30, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
|
||||
let main = s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap()["comp"].as_u64().unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Bottom", "color": "#406080"})).unwrap();
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(3);
|
||||
(h, main, clip)
|
||||
}
|
||||
|
||||
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
|
||||
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone();
|
||||
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
|
||||
}
|
||||
|
||||
/// Press on `from`, move to `to` in steps, release there.
|
||||
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
|
||||
h.event(Event::PointerMoved(from));
|
||||
h.step();
|
||||
h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
for k in 1..=10 {
|
||||
h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 10.0)));
|
||||
h.step();
|
||||
}
|
||||
h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn stack(h: &Harness<'_, EffectcraftApp>) -> Vec<String> {
|
||||
h.state().session.active_comp().unwrap().layers.iter().map(|l| l.name.clone()).collect()
|
||||
}
|
||||
|
||||
fn layer_id(h: &Harness<'_, EffectcraftApp>, name: &str) -> u64 {
|
||||
h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == name).unwrap().id.0
|
||||
}
|
||||
|
||||
/// Both the composition and the render output are selected: dragging the file onto the Timeline
|
||||
/// still lands the file, and the composition is not carried along into itself.
|
||||
#[test]
|
||||
fn a_selection_holding_the_drop_target_does_not_fail_the_drop() {
|
||||
let (mut h, main, clip) = harness();
|
||||
h.state_mut().session.execute("project.select", json!({"items": [main, clip]})).unwrap();
|
||||
h.run_steps(2);
|
||||
let from = rect(&h, &format!("project.item.{clip}.name")).center();
|
||||
let row = rect(&h, &format!("timeline.layer.{}.row", layer_id(&h, "Bottom")));
|
||||
drag(&mut h, from, row.center());
|
||||
assert_eq!(stack(&h), ["Bottom", "Clip"], "the composition the drop lands in is left out");
|
||||
assert!(!h.state().ui.status.contains("contain itself"), "the drop did not fail: {}", h.state().ui.status);
|
||||
}
|
||||
|
||||
/// Dragging the composition's own row onto its Timeline adds nothing and reports nothing.
|
||||
#[test]
|
||||
fn the_composition_dropped_on_itself_adds_nothing() {
|
||||
let (mut h, main, _clip) = harness();
|
||||
h.state_mut().session.execute("project.select", json!({"items": [main]})).unwrap();
|
||||
h.run_steps(2);
|
||||
let before = stack(&h);
|
||||
let from = rect(&h, &format!("project.item.{main}.name")).center();
|
||||
let row = rect(&h, &format!("timeline.layer.{}.row", layer_id(&h, "Bottom")));
|
||||
drag(&mut h, from, row.center());
|
||||
assert_eq!(stack(&h), before, "nothing is added");
|
||||
assert!(!h.state().ui.status.contains("contain itself"), "no error: {}", h.state().ui.status);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -8,6 +8,7 @@ use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::dock::PanelKind;
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
fn app() -> EffectcraftApp {
|
||||
@@ -580,7 +581,7 @@ fn type_tool_selected_text_preference_skips_ineligible_layers_and_falls_back_to_
|
||||
}
|
||||
|
||||
/// Opens a fresh Type-tool edit holding `text` and returns the harness with the new layer id,
|
||||
/// so the Deselect All tests start from the same state.
|
||||
/// so the Deselect All and Duplicate tests start from the same state.
|
||||
fn type_tool_edit(a: EffectcraftApp, text: &str) -> (Harness<'static, EffectcraftApp>, u64) {
|
||||
let mut h = harness(a);
|
||||
h.run_steps(3);
|
||||
@@ -728,6 +729,110 @@ fn deselect_all_menu_item_follows_the_edit_while_the_project_panel_is_focused()
|
||||
assert!(!enabled(&h), "Deselect All follows the empty Project selection once the edit is over");
|
||||
}
|
||||
|
||||
/// Layer ids of the active comp, bottom to top.
|
||||
fn layer_ids(h: &Harness<'_, EffectcraftApp>) -> Vec<u64> {
|
||||
h.state().session.active_comp().map_or(vec![], |c| c.layers.iter().map(|l| l.id.0).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_chord_ends_a_type_tool_edit_and_selects_the_copy() {
|
||||
let (mut h, lid) = type_tool_edit(app(), "Hello");
|
||||
key(&mut h, Key::D, Modifiers::COMMAND);
|
||||
let s = &h.state().session;
|
||||
assert!(s.state.text_edit.is_none(), "Duplicate ends the open edit, as Deselect All does");
|
||||
let copy = *s.state.selected_layers.first().expect("the copy is selected");
|
||||
assert_ne!(copy.0, lid, "the copy (not the edited original) holds the selection");
|
||||
assert_eq!(layer_doc(s, LayerId(lid)).unwrap().text, "Hello", "the original keeps its text");
|
||||
assert_eq!(layer_doc(s, copy).unwrap().text, "Hello", "the copy carries it too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_chord_ends_an_edit_started_while_the_project_panel_is_focused() {
|
||||
let (mut h, lid) = project_focused_text_edit("Hi");
|
||||
key(&mut h, Key::D, Modifiers::COMMAND);
|
||||
let s = &h.state().session;
|
||||
assert!(s.state.text_edit.is_none(), "Cmd+D ends the edit even with Project focused");
|
||||
let copy = s.state.selected_layers.first().expect("the copy is selected");
|
||||
assert_eq!(layer_doc(s, LayerId(lid)).unwrap().text, "Hi", "the typed text is kept");
|
||||
assert_eq!(layer_doc(s, *copy).unwrap().text, "Hi", "on the copy as well");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebound_duplicate_chord_forwards_from_inside_the_edit() {
|
||||
let mut a = app();
|
||||
a.session.execute("shortcuts.set", json!({"command": "edit.duplicate", "keys": ["Cmd+Alt+D"]})).unwrap();
|
||||
let (mut h, lid) = type_tool_edit(a, "Hello");
|
||||
// The default key no longer belongs to Duplicate: while the editor holds the keyboard a
|
||||
// chord that matches no command does nothing, and the current binding is what forwards.
|
||||
let n = layer_ids(&h).len();
|
||||
key(&mut h, Key::D, Modifiers::COMMAND);
|
||||
assert!(h.state().session.state.text_edit.is_some(), "Cmd+D was rebound away: the edit stays open");
|
||||
assert_eq!(layer_ids(&h).len(), n, "and nothing was duplicated");
|
||||
key(&mut h, Key::D, Modifiers { alt: true, ..Modifiers::COMMAND });
|
||||
let s = &h.state().session;
|
||||
assert!(s.state.text_edit.is_none(), "the rebound chord ends the edit");
|
||||
assert_eq!(layer_doc(s, LayerId(lid)).unwrap().text, "Hello", "the text is kept");
|
||||
assert_eq!(layer_ids(&h).len(), n + 1, "and the copy exists");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_rebound_onto_a_bare_letter_stays_typing() {
|
||||
let mut a = app();
|
||||
a.session.execute("shortcuts.set", json!({"command": "edit.duplicate", "keys": ["D"]})).unwrap();
|
||||
let (mut h, lid) = type_tool_edit(a, "Hello");
|
||||
let n = layer_ids(&h).len();
|
||||
// A binding without a command modifier is text as far as the editor is concerned: the editor
|
||||
// must not surrender a key that types (same invariant as the app-wide dispatcher).
|
||||
key(&mut h, Key::D, Modifiers::NONE);
|
||||
assert!(h.state().session.state.text_edit.is_some(), "a bare-letter Duplicate binding does not end the edit");
|
||||
assert_eq!(layer_ids(&h).len(), n, "and does not duplicate");
|
||||
type_text(&mut h, "D");
|
||||
let (lid2, text, _, _) = edited(&h);
|
||||
assert_eq!(lid2, lid, "the same edit on the same layer continues");
|
||||
assert_eq!(text, "HelloD", "and the letter typed into the text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_chord_copies_the_edited_layer_not_an_effect_still_selected() {
|
||||
let mut a = app();
|
||||
let lid = a.session.execute("layer.newText", json!({"text": "alpha beta", "size": 40, "box": [40, 60, 250, 100]})).unwrap()["layer"].as_u64().unwrap();
|
||||
// Applying the blur selects it, as clicking it in Effect Controls does.
|
||||
a.session.execute("effect.apply", json!({"layers": [lid], "effect": "Gaussian Blur"})).unwrap();
|
||||
a.ui.tool = effectcraft_ui_egui::state::Tool::Type;
|
||||
let mut h = harness(a);
|
||||
h.run_steps(3);
|
||||
let comp = rect(&h, "viewer.comp");
|
||||
click(&mut h, comp.min + egui::vec2(80.0, 85.0) * (comp.width() / 640.0), Modifiers::NONE);
|
||||
assert_eq!(edited(&h).0, lid, "the Type tool's click opens the text layer for editing");
|
||||
assert!(!h.state().session.state.selected_props.is_empty(), "and the blur stays selected");
|
||||
let n = layer_ids(&h).len();
|
||||
key(&mut h, Key::D, Modifiers::COMMAND);
|
||||
let s = &h.state().session;
|
||||
assert!(s.state.text_edit.is_none(), "Duplicate ends the edit");
|
||||
assert_eq!(layer_ids(&h).len(), n + 1, "and copies the edited layer");
|
||||
let blurs = s.active_comp().unwrap().layer(LayerId(lid)).unwrap().effects().map_or(0, |fx| fx.groups().count());
|
||||
assert_eq!(blurs, 1, "not the blur");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chord_bound_to_both_forwarded_commands_resolves_in_list_order() {
|
||||
let mut a = app();
|
||||
// Shortcuts may hold one chord for several commands (the conflict is a toast, not a
|
||||
// refusal), so the editor's forwarding resolves a shared chord in list order — Deselect
|
||||
// All before Duplicate. This pins that documented contract: the shared chord must end the
|
||||
// edit like Deselect All, without running Duplicate behind it.
|
||||
a.session.execute("shortcuts.set", json!({"command": "edit.deselectAll", "keys": ["Cmd+Alt+G"]})).unwrap();
|
||||
a.session.execute("shortcuts.set", json!({"command": "edit.duplicate", "keys": ["Cmd+Alt+G"]})).unwrap();
|
||||
let (mut h, lid) = type_tool_edit(a, "Hello");
|
||||
let n = layer_ids(&h).len();
|
||||
key(&mut h, Key::G, Modifiers { alt: true, ..Modifiers::COMMAND });
|
||||
let s = &h.state().session;
|
||||
assert!(s.state.text_edit.is_none(), "the shared chord still ends the edit");
|
||||
assert!(s.state.selected_layers.is_empty(), "Deselect All wins the shared chord: nothing is selected");
|
||||
assert_eq!(layer_ids(&h).len(), n, "so the chord did not duplicate");
|
||||
assert_eq!(layer_doc(s, LayerId(lid)).unwrap().text, "Hello", "and the text is kept");
|
||||
}
|
||||
|
||||
/// The on-canvas editor's outgoing IME producer: the descriptor carries the edited layer's
|
||||
/// committed text, the real owner, a stable generation and an explicit change cause. A normal
|
||||
/// typed character is application work; an accepted input-method commit is not.
|
||||
@@ -1750,3 +1855,19 @@ fn character_panel_line_join_buttons_set_the_join() {
|
||||
click(&mut h, bevel, Modifiers::NONE);
|
||||
assert_eq!(join(&h), effectcraft_engine::keyframe::LineJoin::Bevel);
|
||||
}
|
||||
|
||||
/// #2075: the Character panel's stroke width has a presets menu (0.25 … 100 px) beside its value.
|
||||
#[test]
|
||||
fn character_panel_stroke_width_presets_set_the_width() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap();
|
||||
let lid = s.execute("layer.newText", json!({"text": "Stroke", "size": 80, "applyStroke": true, "strokeWidth": 8})).unwrap()["layer"].as_u64().unwrap();
|
||||
let mut h = harness(EffectcraftApp::new(s));
|
||||
h.state_mut().show_panel(PanelKind::Character);
|
||||
h.run_steps(3);
|
||||
let arrow = rect(&h, "character.strokeWidth.presets").center();
|
||||
click(&mut h, arrow, Modifiers::NONE);
|
||||
let entry = h.get_by_label("0.5 px").rect().center();
|
||||
click(&mut h, entry, Modifiers::NONE);
|
||||
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().stroke_width, 0.5);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Audio Levels' channel link in the Timeline (#2485): off by default; linked, an edit to one
|
||||
//! channel moves the other by the same dB, so the stereo balance stays, typed or scrubbed, on a
|
||||
//! static or animated property, and one Undo takes it back.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::LayerId;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 {
|
||||
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
|
||||
pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)
|
||||
}
|
||||
|
||||
/// Press on `from`, move to `to` in `steps`, release there.
|
||||
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, steps: u32) {
|
||||
h.input_mut().events.push(Event::PointerMoved(from));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
for k in 1..=steps {
|
||||
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / steps as f32)));
|
||||
h.step();
|
||||
}
|
||||
h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
|
||||
let p = center(h, id);
|
||||
drag(h, p, p, 1);
|
||||
}
|
||||
|
||||
fn type_value(h: &mut Harness<'_, EffectcraftApp>, id: &str, v: &str) {
|
||||
click(h, id);
|
||||
h.input_mut().events.push(Event::Text(v.into()));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
/// A precomp layer (which has Audio Levels) in comp Main at `levels`; its layer id and the
|
||||
/// property's uid.
|
||||
fn add_sound_layer(s: &mut Session, levels: [f64; 2]) -> (u64, u64) {
|
||||
let l = s.execute("layer.addItem", json!({"item": "Sound"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": l, "path": "audio/levels", "value": levels})).unwrap();
|
||||
(l, s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("audio/levels").unwrap().uid)
|
||||
}
|
||||
|
||||
/// Comps Sound and Main.
|
||||
fn comps(s: &mut Session) {
|
||||
s.execute("comp.new", json!({"name": "Sound", "width": 64, "height": 36, "duration": 4})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap();
|
||||
}
|
||||
|
||||
/// Twirl layer `l` open to its property `uid`.
|
||||
fn reveal(h: &mut Harness<'_, EffectcraftApp>, l: u64, uid: u64) {
|
||||
h.state_mut().ui.timeline.open_layers.insert(l);
|
||||
h.state_mut().ui.timeline.layer_reveal.insert(l, vec!["props".into()]);
|
||||
h.state_mut().ui.timeline.reveal_props.insert(uid);
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn levels_of(h: &Harness<'_, EffectcraftApp>, l: u64) -> Vec<f64> {
|
||||
h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("audio/levels").unwrap().value.components()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_audio_levels_move_both_channels_by_the_same_db() {
|
||||
let mut s = Session::default();
|
||||
comps(&mut s);
|
||||
let (l, uid) = add_sound_layer(&mut s, [-6.0, -12.0]);
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
reveal(&mut h, l, uid);
|
||||
let levels =
|
||||
|h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("audio/levels").unwrap().clone();
|
||||
let set = |h: &mut Harness<'_, EffectcraftApp>, v: [f64; 2]| {
|
||||
h.state_mut().session.execute("prop.set", json!({"layer": l, "path": "audio/levels", "value": v})).unwrap();
|
||||
h.run_steps(2);
|
||||
};
|
||||
let undo = |h: &mut Harness<'_, EffectcraftApp>| {
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
};
|
||||
let (left, right, link) = (format!("timeline.prop.{uid}.value.0"), format!("timeline.prop.{uid}.value.1"), format!("timeline.prop.{uid}.link"));
|
||||
assert_eq!(h.state().auto.find(&link).unwrap().label, "Link Channels");
|
||||
|
||||
type_value(&mut h, &left, "-3");
|
||||
assert_eq!(levels(&h).value.components(), vec![-3.0, -12.0], "unlinked by default, one channel moves alone");
|
||||
set(&mut h, [-6.0, -12.0]);
|
||||
|
||||
click(&mut h, &link);
|
||||
type_value(&mut h, &left, "-3");
|
||||
assert_eq!(levels(&h).value.components(), vec![-3.0, -9.0], "linked, both rise 3 dB and keep their 6 dB balance");
|
||||
undo(&mut h);
|
||||
assert_eq!(levels(&h).value.components(), vec![-6.0, -12.0], "one Undo takes back both channels");
|
||||
|
||||
type_value(&mut h, &right, "0");
|
||||
assert_eq!(levels(&h).value.components(), vec![6.0, 0.0], "the right channel leads just the same");
|
||||
set(&mut h, [0.0, 0.0]);
|
||||
type_value(&mut h, &left, "-6");
|
||||
assert_eq!(levels(&h).value.components(), vec![-6.0, -6.0], "equal channels at 0 dB stay equal");
|
||||
|
||||
set(&mut h, [-6.0, -12.0]);
|
||||
let from = center(&h, &left);
|
||||
drag(&mut h, from, from + vec2(30.0, 0.0), 6);
|
||||
let c = levels(&h).value.components();
|
||||
assert!(c[0] > -6.0, "the scrub raised the left channel: {c:?}");
|
||||
assert!((c[0] - c[1] - 6.0).abs() < 1e-9, "a linked scrub keeps the balance: {c:?}");
|
||||
undo(&mut h);
|
||||
assert_eq!(levels(&h).value.components(), vec![-6.0, -12.0], "one Undo takes back the whole scrub");
|
||||
|
||||
// Animated: the edit at the current time changes that key's two channels together.
|
||||
h.state_mut().session.execute("prop.addKey", json!({"layer": l, "path": "audio/levels", "time": 0.0, "value": [-6.0, -12.0]})).unwrap();
|
||||
h.state_mut().session.execute("prop.addKey", json!({"layer": l, "path": "audio/levels", "time": 1.0, "value": [0.0, 0.0]})).unwrap();
|
||||
h.run_steps(2);
|
||||
type_value(&mut h, &left, "-10");
|
||||
let keys: Vec<Vec<f64>> = levels(&h).keys.iter().map(|k| k.value.components()).collect();
|
||||
assert_eq!(keys, vec![vec![-10.0, -16.0], vec![0.0, 0.0]], "the key at the current time moves both channels");
|
||||
|
||||
click(&mut h, &link);
|
||||
type_value(&mut h, &left, "-4");
|
||||
let keys: Vec<Vec<f64>> = levels(&h).keys.iter().map(|k| k.value.components()).collect();
|
||||
assert_eq!(keys[0], vec![-4.0, -16.0], "unlinked again, the right channel stays");
|
||||
}
|
||||
|
||||
/// With several layers selected, a linked typed Left sets each layer's Left and moves its Right
|
||||
/// by that layer's own change: every layer keeps its own balance.
|
||||
#[test]
|
||||
fn linked_typing_keeps_each_selected_layers_own_balance() {
|
||||
let mut s = Session::default();
|
||||
comps(&mut s);
|
||||
let (a, uid) = add_sound_layer(&mut s, [-6.0, -12.0]);
|
||||
let (b, other) = add_sound_layer(&mut s, [-20.0, -30.0]);
|
||||
s.execute("layer.select", json!({"layers": [a, b]})).unwrap();
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
reveal(&mut h, a, uid);
|
||||
reveal(&mut h, b, other);
|
||||
click(&mut h, &format!("timeline.prop.{uid}.link"));
|
||||
type_value(&mut h, &format!("timeline.prop.{uid}.value.0"), "-3");
|
||||
assert_eq!(levels_of(&h, a), vec![-3.0, -9.0]);
|
||||
assert_eq!(levels_of(&h, b), vec![-3.0, -13.0], "B rises 17 dB on both channels, keeping its 10 dB balance");
|
||||
}
|
||||
|
||||
/// A link turned on does not carry into a new project whose Audio Levels reuse the property uid.
|
||||
#[test]
|
||||
fn a_new_project_starts_with_its_audio_levels_unlinked() {
|
||||
let mut s = Session::default();
|
||||
comps(&mut s);
|
||||
let (l, uid) = add_sound_layer(&mut s, [0.0, 0.0]);
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
reveal(&mut h, l, uid);
|
||||
click(&mut h, &format!("timeline.prop.{uid}.link"));
|
||||
assert!(h.state().ui.timeline.is_linked(uid, false));
|
||||
|
||||
h.state_mut().session.execute("file.newProject", json!({})).unwrap();
|
||||
comps(&mut h.state_mut().session);
|
||||
let (l, again) = add_sound_layer(&mut h.state_mut().session, [0.0, 0.0]);
|
||||
assert_eq!(again, uid, "the new project reuses the property uid");
|
||||
reveal(&mut h, l, again);
|
||||
assert!(!h.state().ui.timeline.is_linked(again, false), "the new project's levels start unlinked");
|
||||
type_value(&mut h, &format!("timeline.prop.{again}.value.0"), "-3");
|
||||
assert_eq!(levels_of(&h, l), vec![-3.0, 0.0]);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Shift-clicking a group row in the Timeline adds it to the selection, as on a property row, so
|
||||
//! several Puppet pins can be selected and deleted together (#1751).
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::{LayerId, PropGroup};
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::dock::PanelKind;
|
||||
use egui::{Modifiers, Pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
fn harness() -> (Harness<'static, EffectcraftApp>, u64, [u64; 3]) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 200, "height": 100, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let layer = s.execute("layer.newSolid", json!({"width": 100, "height": 60, "color": [0, 0, 1]})).unwrap()["layer"].as_u64().unwrap();
|
||||
let pin = |s: &mut Session, x: i64| s.execute("puppet.addPin", json!({"layer": layer, "position": [x, 30]})).unwrap()["pin"].as_u64().unwrap();
|
||||
let pins = [pin(&mut s, 10), pin(&mut s, 50), pin(&mut s, 90)];
|
||||
let mut app = EffectcraftApp::new(s);
|
||||
app.show_panel(PanelKind::Timeline);
|
||||
app.ui.timeline.open_layers.insert(layer);
|
||||
// Twirl open every group down to the pins.
|
||||
fn all(g: &PropGroup, out: &mut Vec<u64>) {
|
||||
for c in g.groups() {
|
||||
out.push(c.uid);
|
||||
all(c, out);
|
||||
}
|
||||
}
|
||||
let mut open = vec![];
|
||||
if let Some(l) = app.session.active_comp().and_then(|c| c.layer(LayerId(layer))) {
|
||||
all(&l.props, &mut open);
|
||||
}
|
||||
app.ui.timeline.open_groups.extend(open);
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1100.0)).build_eframe(|_| app);
|
||||
h.run_steps(4);
|
||||
(h, layer, pins)
|
||||
}
|
||||
|
||||
fn row(h: &Harness<'_, EffectcraftApp>, uid: u64) -> Pos2 {
|
||||
let id = format!("timeline.group.{uid}.name");
|
||||
let e = h.state().auto.find(&id).unwrap_or_else(|| panic!("no {id}"));
|
||||
egui::pos2(e.rect[0] + e.rect[2].min(40.0) / 2.0, e.rect[1] + e.rect[3] / 2.0)
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness<'_, EffectcraftApp>, uid: u64, modifiers: Modifiers) {
|
||||
let at = row(h, uid);
|
||||
h.input_mut().events.push(egui::Event::ModifiersChanged(modifiers));
|
||||
h.input_mut().events.push(egui::Event::PointerMoved(at));
|
||||
h.input_mut().events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers });
|
||||
h.input_mut().events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers });
|
||||
h.run_steps(3);
|
||||
h.input_mut().events.push(egui::Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.step();
|
||||
}
|
||||
|
||||
fn selected(h: &Harness<'_, EffectcraftApp>, pins: [u64; 3]) -> Vec<u64> {
|
||||
h.state().session.state.selected_props.iter().map(|(_, u)| *u).filter(|u| pins.contains(u)).collect()
|
||||
}
|
||||
|
||||
fn pin_count(h: &Harness<'_, EffectcraftApp>, layer: u64) -> usize {
|
||||
let l = h.state().session.active_comp().unwrap().layer(LayerId(layer)).unwrap();
|
||||
l.effects().unwrap().groups().map(effectcraft_engine::effects::puppet::pin_count).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_click_adds_puppet_pins_and_delete_removes_them_together() {
|
||||
let (mut h, layer, pins) = harness();
|
||||
let [a, b, c] = pins;
|
||||
click(&mut h, a, Modifiers::NONE);
|
||||
assert_eq!(selected(&h, pins), [a]);
|
||||
click(&mut h, b, Modifiers::SHIFT);
|
||||
assert_eq!(selected(&h, pins), [a, b], "Shift-click keeps the first pin");
|
||||
click(&mut h, c, Modifiers::NONE);
|
||||
assert_eq!(selected(&h, pins), [c], "a plain click selects only the clicked pin");
|
||||
|
||||
click(&mut h, a, Modifiers::NONE);
|
||||
click(&mut h, b, Modifiers::SHIFT);
|
||||
h.state_mut().session.execute("edit.clear", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_eq!(pin_count(&h, layer), 1, "Delete removes both selected pins");
|
||||
assert!(h.state().session.active_comp().unwrap().layer(LayerId(layer)).is_some(), "the layer stays");
|
||||
h.state_mut().session.undo();
|
||||
assert_eq!(pin_count(&h, layer), 3, "one Undo restores both pins");
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user