mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 07:38:24 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
commit
a2d44e1020
57 files changed
+2622
-208
No files matched your search
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=1065653e75854e9c17e1ff563acdd344c1b185f0#1065653e75854e9c17e1ff563acdd344c1b185f0"
|
||||
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 with storytold/craft-libs#434: the update prompt shows the app's icon, accent and developer.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1065653e75854e9c17e1ff563acdd344c1b185f0", 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
|
||||
|
||||
@@ -148,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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")).changed() {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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", "エフェクト&プリセットのオプション"),
|
||||
@@ -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", "読み込みの種類"),
|
||||
@@ -1066,6 +1101,14 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("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", "新しいフォルダーを作成"),
|
||||
@@ -1088,6 +1131,9 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("ArtCraft website", "ArtCraftのウェブサイト"),
|
||||
("Source code on GitHub", "GitHubのソースコード"),
|
||||
("3D Renderer", "3Dレンダラー"),
|
||||
("Black", "黒"),
|
||||
("White", "白"),
|
||||
("Composition Settings", "コンポジション設定"),
|
||||
("Preset Name", "プリセット名"),
|
||||
("Save Preset…", "プリセットを保存…"),
|
||||
("Delete Preset", "プリセットを削除"),
|
||||
@@ -1183,6 +1229,17 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("* 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", "エッジのブレンド"),
|
||||
@@ -1220,6 +1277,19 @@ const JAPANESE: &[(&str, &str)] = &[
|
||||
("Blend Mode", "描画モード"),
|
||||
("Show mesh", "メッシュを表示"),
|
||||
("Record Options...", "記録オプション..."),
|
||||
("Apply", "適用"),
|
||||
("Analyze", "分析"),
|
||||
("Add Point", "ポイントを追加"),
|
||||
("Remove Point", "ポイントを削除"),
|
||||
("Render", "レンダリング"),
|
||||
("Save Current as Template…", "テンプレートとして保存…"),
|
||||
("New Composition From Footage", "フッテージから新規コンポジション"),
|
||||
("Search project", "プロジェクトを検索"),
|
||||
("Search", "検索"),
|
||||
("(no composition)", "(コンポジションなし)"),
|
||||
("per second", "毎秒"),
|
||||
("Waveform", "波形"),
|
||||
("steps", "ステップ"),
|
||||
];
|
||||
|
||||
const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
@@ -1360,7 +1430,6 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
("Duration:", "持续时间:"),
|
||||
("Edit Gradient…", "编辑渐变…"),
|
||||
("Edit Target: {}", "编辑目标:{}"),
|
||||
("Edit in the Timeline", "在时间轴中编辑"),
|
||||
("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常退出。"),
|
||||
("Effects", "效果"),
|
||||
("Effects & Presets options", "效果和预设选项"),
|
||||
@@ -2469,7 +2538,6 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
||||
("Duration:", "持續時間:"),
|
||||
("Edit Gradient…", "編輯漸變…"),
|
||||
("Edit Target: {}", "編輯目標:{}"),
|
||||
("Edit in the Timeline", "在時間軸中編輯"),
|
||||
("EffectCraft didn't quit normally last time.", "EffectCraft 上次未正常結束。"),
|
||||
("Effects", "效果"),
|
||||
("Effects & Presets options", "效果和預設集選項"),
|
||||
@@ -3531,7 +3599,6 @@ const UKRAINIAN: &[(&str, &str)] = &[
|
||||
("Duration:", "Тривалість:"),
|
||||
("Edit Gradient…", "Редагувати градієнт…"),
|
||||
("Edit Target: {}", "Ціль редагування: {}"),
|
||||
("Edit in the Timeline", "Редагувати на шкалі часу"),
|
||||
("EffectCraft didn't quit normally last time.", "Минулого разу EffectCraft завершив роботу некоректно."),
|
||||
("Effects", "Ефекти"),
|
||||
("Effects & Presets options", "Параметри ефектів і наборів"),
|
||||
@@ -4095,7 +4162,6 @@ const PORTUGUESE: &[(&str, &str)] = &[
|
||||
("Duration:", "Duração:"),
|
||||
("Edit Gradient…", "Editar gradiente…"),
|
||||
("Edit Target: {}", "Editar destino: {}"),
|
||||
("Edit in the Timeline", "Editar na linha do tempo"),
|
||||
("EffectCraft didn't quit normally last time.", "O EffectCraft não foi encerrado normalmente da última vez."),
|
||||
("Effects", "Efeitos"),
|
||||
("Effects & Presets options", "Opções de Efeitos e predefinições"),
|
||||
@@ -4858,7 +4924,6 @@ const POLISH: &[(&str, &str)] = &[
|
||||
("Duration:", "Czas trwania:"),
|
||||
("Edit Gradient…", "Edytuj gradient…"),
|
||||
("Edit Target: {}", "Cel edycji: {}"),
|
||||
("Edit in the Timeline", "Edytuj na osi czasu"),
|
||||
("EffectCraft didn't quit normally last time.", "EffectCraft nie zakończył się poprawnie ostatnim razem."),
|
||||
("Effects & Presets options", "Opcje efektów i ustawień"),
|
||||
("Copy Presets Folder Path", "Kopiuj ścieżkę folderu ustawień"),
|
||||
@@ -5603,7 +5668,6 @@ const CZECH: &[(&str, &str)] = &[
|
||||
("Duration:", "Délka trvání:"),
|
||||
("Edit Gradient…", "Upravit přechod…"),
|
||||
("Edit Target: {}", "Upravit cíl: {}"),
|
||||
("Edit in the Timeline", "Upravit na časové ose"),
|
||||
("EffectCraft didn't quit normally last time.", "EffectCraft nebyl při posledním použití ukončen běžným způsobem."),
|
||||
("Effects", "Efekty"),
|
||||
("Effects & Presets options", "Možnosti efektů a přednastavení"),
|
||||
@@ -6171,7 +6235,6 @@ const SPANISH: &[(&str, &str)] = &[
|
||||
("Duration:", "Duración:"),
|
||||
("Edit Gradient…", "Editar degradado…"),
|
||||
("Edit Target: {}", "Destino de la edición: {}"),
|
||||
("Edit in the Timeline", "Editar en la cronología"),
|
||||
("EffectCraft didn't quit normally last time.", "EffectCraft no se cerró normalmente la última vez."),
|
||||
("Effects", "Efectos"),
|
||||
("Effects & Presets options", "Opciones de Efectos y ajustes preestablecidos"),
|
||||
@@ -6723,7 +6786,6 @@ const GEORGIAN: &[(&str, &str)] = &[
|
||||
("Duration:", "ხანგრძლივობა:"),
|
||||
("Edit Gradient…", "გრადიენტის რედაქტირება…"),
|
||||
("Edit Target: {}", "რედაქტირების სამიზნე: {}"),
|
||||
("Edit in the Timeline", "რედაქტირება დროის ხაზზე"),
|
||||
("EffectCraft didn't quit normally last time.", "EffectCraft ბოლოს ნორმალურად არ დახურულა."),
|
||||
("Effects", "ეფექტები"),
|
||||
("Effects & Presets options", "ეფექტებისა და პრესეტების პარამეტრები"),
|
||||
|
||||
@@ -7,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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,7 +379,7 @@ fn value_toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value:
|
||||
let PropCx { t, uid, cy, vx, p, prop, .. } = *cx;
|
||||
let cr = Rect::from_min_size(pos2(vx, cy - 8.0), vec2(16.0, 16.0));
|
||||
if widgets::checkbox(ui, cr, *b, &t, egui::Id::unique(("ec-b", uid))).changed() {
|
||||
cx.set(actions, json!(!b));
|
||||
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()),
|
||||
|
||||
@@ -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![
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")).changed() {
|
||||
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 ui.add_enabled_ui(!rendering, |ui| widgets::checkbox(ui, cb, it.render, &t, egui::Id::unique(("rq-cb", it.id)))).inner.changed() && !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,6 +106,8 @@ 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);
|
||||
@@ -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();
|
||||
|
||||
@@ -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| {
|
||||
@@ -4964,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()})));
|
||||
}
|
||||
}
|
||||
@@ -5600,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}),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -5785,7 +5795,7 @@ fn bool_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>,
|
||||
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))).changed() {
|
||||
ve.set(actions, json!(!b));
|
||||
ve.set_step(actions, json!(!b));
|
||||
}
|
||||
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
|
||||
}
|
||||
@@ -5805,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);
|
||||
@@ -5825,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);
|
||||
}
|
||||
|
||||
@@ -1701,8 +1701,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() };
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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,6 +423,8 @@ 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
|
||||
}
|
||||
|
||||
@@ -589,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
|
||||
}
|
||||
|
||||
@@ -608,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])) {
|
||||
@@ -652,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) {
|
||||
@@ -736,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) {
|
||||
@@ -745,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
|
||||
|
||||
@@ -69,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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -503,8 +503,10 @@ fn text_animate_menu_adds_an_animator_with_the_property() {
|
||||
let mut h = harness(s);
|
||||
h.state_mut().ui.timeline.open_groups.insert(text);
|
||||
open_layer(&mut h, l);
|
||||
// The Properties panel's Position row has the same accessible name: take the menu's new entry.
|
||||
let shown: Vec<_> = h.query_all_by_label("Position").map(|n| n.rect()).collect();
|
||||
click(&mut h, &format!("timeline.group.{text}.animate"));
|
||||
let entry = h.get_by_label("Position").rect();
|
||||
let entry = h.query_all_by_label("Position").map(|n| n.rect()).find(|r| !shown.contains(r)).expect("the Animate menu's Position entry");
|
||||
click_at(&mut h, entry.center());
|
||||
let animators = |h: &Harness<'_, EffectcraftApp>| {
|
||||
h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("text").unwrap().sub("animators").cloned()
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
//! Accessibility audit (docs/gaps.md gap 25): renders panels headless and walks the AccessKit
|
||||
//! tree of the frame. Every interactive node (button, check box, text input, combo box, slider,
|
||||
//! spin button, tree or list item, tab, link, ...) must have a non-empty name, a role that says
|
||||
//! what it is, and the state that role needs (checked, selected, a value).
|
||||
//!
|
||||
//! * The panels that had their pass (`FIXED`: Effects & Presets, Settings, Render Queue with its
|
||||
//! Templates dialog) must have **zero** findings.
|
||||
//! * Every other panel is counted and printed (run with `--nocapture`), and checked against
|
||||
//! [`BASELINE`], a ratchet that can only shrink: fixing a panel lowers its number, and a number
|
||||
//! must never go up. When a panel's count falls, the test fails until its entry is lowered.
|
||||
//!
|
||||
//! What this does not measure: how a real screen reader speaks the tree (none was run), colour
|
||||
//! contrast, focus order or keyboard operation (the `ui_*_keyboard.rs` tests cover those), or
|
||||
//! widgets that are scrolled out of view and not drawn.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::dock::PanelKind;
|
||||
use egui::accesskit::{Action, Role, Toggled};
|
||||
use egui::{Rect, pos2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::NodeT;
|
||||
use serde_json::json;
|
||||
|
||||
/// Groups that must be clean. A group is the first part of a scenario name (before the `:`).
|
||||
const FIXED: &[&str] = &["EffectsPresets", "Settings", "RenderQueue", "Project"];
|
||||
|
||||
/// Scenarios whose content is the machine's own folder listing (the Media Browser opens on the
|
||||
/// home folder), so a runner with fewer files finds fewer controls. They are held to the upper bound
|
||||
/// only: the "improved, lower the entry" check would fail on any machine with a different home.
|
||||
const MACHINE_DEPENDENT: &[&str] = &["MediaBrowser:default"];
|
||||
|
||||
/// (scenario, findings, of them unnamed controls) per not-yet-fixed scenario, as measured in the
|
||||
/// shared docking layout (Effect Controls with one Gaussian Blur on the layer, every other panel
|
||||
/// as it opens). A ratchet: a number may only go down, never up; when a pass over a panel lowers
|
||||
/// it, lower its entry in the same change, and remove the entry (and add the panel to `FIXED`)
|
||||
/// when it reaches zero. A scenario that is not listed is held to zero.
|
||||
const BASELINE: &[(&str, usize, usize)] = &[
|
||||
("EffectControls:default", 28, 14),
|
||||
("Composition:default", 4, 2),
|
||||
("Layer:default", 2, 1),
|
||||
("Timeline:default", 89, 42),
|
||||
("Audio:default", 4, 2),
|
||||
("Properties:default", 31, 15),
|
||||
("Character:default", 46, 22),
|
||||
("Paragraph:default", 26, 13),
|
||||
("Align:default", 12, 0),
|
||||
("Tracker:default", 11, 3),
|
||||
("Wiggler:default", 4, 2),
|
||||
("Smoother:default", 2, 1),
|
||||
("MotionSketch:default", 8, 4),
|
||||
("Paint:default", 38, 18),
|
||||
("Brushes:default", 34, 6),
|
||||
("Flowchart:default", 5, 1),
|
||||
("History:default", 10, 5),
|
||||
("MaskInterpolation:default", 16, 8),
|
||||
("ScriptConsole:default", 5, 3),
|
||||
("EssentialGraphics:default", 1, 1),
|
||||
("LumetriScopes:default", 2, 1),
|
||||
("MediaBrowser:default", 20, 10),
|
||||
("Metadata:default", 2, 2),
|
||||
("ContentAwareFill:default", 2, 1),
|
||||
];
|
||||
|
||||
/// How many controls the fixed scenarios must at least show, so that "zero findings" is never
|
||||
/// the result of looking at nothing. Any scenario not listed needs 2.
|
||||
const MIN_CONTROLS: &[(&str, usize)] = &[
|
||||
("EffectsPresets:default", 10),
|
||||
("EffectsPresets:open", 10),
|
||||
("EffectsPresets:search", 5),
|
||||
("EffectsPresets:menu", 8),
|
||||
("Settings:general", 12),
|
||||
("Settings:appearance", 15),
|
||||
("Settings:labels", 30),
|
||||
("RenderQueue:queued", 8),
|
||||
("RenderQueue:expanded", 12),
|
||||
("RenderQueue:menu", 20),
|
||||
("RenderQueue:templates:renderSettings", 8),
|
||||
("RenderQueue:templates:outputModule", 8),
|
||||
];
|
||||
|
||||
/// The roles that are controls: anything with one of these (or with a click or focus action) is
|
||||
/// held to the rules below.
|
||||
const CONTROL_ROLES: &[Role] = &[
|
||||
Role::Button,
|
||||
Role::CheckBox,
|
||||
Role::ColorWell,
|
||||
Role::ComboBox,
|
||||
Role::Link,
|
||||
Role::ListBoxOption,
|
||||
Role::ListItem,
|
||||
Role::MenuItem,
|
||||
Role::MenuItemCheckBox,
|
||||
Role::MenuItemRadio,
|
||||
Role::MultilineTextInput,
|
||||
Role::RadioButton,
|
||||
Role::SearchInput,
|
||||
Role::Slider,
|
||||
Role::SpinButton,
|
||||
Role::Switch,
|
||||
Role::Tab,
|
||||
Role::TextInput,
|
||||
Role::TreeItem,
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Finding {
|
||||
scenario: String,
|
||||
problem: &'static str,
|
||||
role: Role,
|
||||
name: String,
|
||||
rect: [i32; 4],
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Finding {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "[{}] {}: {:?} {:?} at {:?}", self.scenario, self.problem, self.role, self.name, self.rect)
|
||||
}
|
||||
}
|
||||
|
||||
fn app_session() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Red", "color": "#c04040"})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Blue", "color": "#4040c0"})).unwrap();
|
||||
s.execute("effect.apply", json!({"effect": "Gaussian Blur"})).ok();
|
||||
s
|
||||
}
|
||||
|
||||
fn harness() -> Harness<'static, EffectcraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(app_session()));
|
||||
// The default workspace gives Preview a short frame at the top of the right column; its
|
||||
// controls below that frame stay in the tree and would be counted against the panel under it.
|
||||
// The per-panel pass opens Preview again for its own scenario.
|
||||
h.state_mut().close_panel(PanelKind::Preview);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn invoke(h: &mut Harness<'_, EffectcraftApp>, id: &str, params: serde_json::Value) {
|
||||
let ctx = h.ctx.clone();
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, id, params).unwrap();
|
||||
h.run_steps(4);
|
||||
}
|
||||
|
||||
fn panel_rect(h: &Harness<'_, EffectcraftApp>, p: PanelKind) -> Option<Rect> {
|
||||
let e = h.state().auto.find(&format!("panel.{}", p.id()))?;
|
||||
Some(Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])))
|
||||
}
|
||||
|
||||
fn dialog_rect(h: &Harness<'_, EffectcraftApp>, title: &str) -> Option<Rect> {
|
||||
h.ctx.memory(|m| m.area_rect(egui::Id::unique(("modal", title))))
|
||||
}
|
||||
|
||||
/// The ids of every node now, so a later audit can look at what appeared since (a popup).
|
||||
fn node_ids(h: &Harness<'_, EffectcraftApp>) -> HashSet<String> {
|
||||
h.root().children_recursive().map(|n| format!("{:?}", n.accesskit_node().id())).collect()
|
||||
}
|
||||
|
||||
/// Walk the tree and check every control whose centre is inside `scope` (and, when `since` is
|
||||
/// given, that was not in the tree before).
|
||||
fn audit(h: &Harness<'_, EffectcraftApp>, scenario: &str, scope: Rect, since: Option<&HashSet<String>>) -> (Vec<Finding>, usize) {
|
||||
let mut out = Vec::new();
|
||||
let mut controls = 0;
|
||||
for n in h.root().children_recursive() {
|
||||
let a = n.accesskit_node();
|
||||
if a.is_hidden() {
|
||||
continue;
|
||||
}
|
||||
let Some(b) = a.bounding_box() else {
|
||||
// No bounds: assistive technology cannot place it. Only a control is a finding (and
|
||||
// it is one wherever it is: there is no position to scope it by).
|
||||
if CONTROL_ROLES.contains(&a.role()) {
|
||||
out.push(Finding { scenario: scenario.to_string(), problem: "no bounds", role: a.role(), name: a.label().unwrap_or_default(), rect: [0; 4] });
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if !scope.contains(pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)) {
|
||||
continue;
|
||||
}
|
||||
if since.is_some_and(|s| s.contains(&format!("{:?}", a.id()))) {
|
||||
continue;
|
||||
}
|
||||
let role = a.role();
|
||||
let actionable = a.data().supports_action(Action::Click) || a.data().supports_action(Action::Focus);
|
||||
let control = CONTROL_ROLES.contains(&role);
|
||||
if !(control || (actionable && matches!(role, Role::Unknown | Role::GenericContainer))) {
|
||||
continue;
|
||||
}
|
||||
let name = a.label().unwrap_or_default();
|
||||
controls += 1;
|
||||
let finding = |problem: &'static str| Finding {
|
||||
scenario: scenario.to_string(),
|
||||
problem,
|
||||
role,
|
||||
name: name.clone(),
|
||||
rect: [b.x0 as i32, b.y0 as i32, b.x1 as i32, b.y1 as i32],
|
||||
};
|
||||
if !control && matches!(role, Role::Unknown | Role::GenericContainer) {
|
||||
// Something you can click or focus, that no role describes.
|
||||
out.push(finding("clickable or focusable without a control role"));
|
||||
}
|
||||
if name.trim().is_empty() {
|
||||
out.push(finding("no name"));
|
||||
}
|
||||
let state_missing = match role {
|
||||
Role::CheckBox | Role::RadioButton | Role::Switch | Role::MenuItemCheckBox | Role::MenuItemRadio => {
|
||||
a.toggled().is_none() || a.toggled() == Some(Toggled::Mixed)
|
||||
}
|
||||
Role::Slider | Role::SpinButton => a.numeric_value().is_none(),
|
||||
Role::ComboBox => a.value().is_none_or(|v| v.trim().is_empty()),
|
||||
Role::TreeItem | Role::ListItem | Role::ListBoxOption | Role::Tab => a.is_selected().is_none(),
|
||||
_ => false,
|
||||
};
|
||||
if state_missing {
|
||||
out.push(finding("no state or value"));
|
||||
}
|
||||
}
|
||||
(out, controls)
|
||||
}
|
||||
|
||||
fn count_unnamed(f: &[Finding]) -> usize {
|
||||
f.iter().filter(|f| f.problem == "no name").count()
|
||||
}
|
||||
|
||||
struct Report {
|
||||
findings: Vec<Finding>,
|
||||
/// (scenario, findings, of them unnamed, controls looked at)
|
||||
rows: Vec<(String, usize, usize, usize)>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
fn add(&mut self, scenario: &str, (f, controls): (Vec<Finding>, usize)) {
|
||||
self.rows.push((scenario.to_string(), f.len(), count_unnamed(&f), controls));
|
||||
self.findings.extend(f);
|
||||
}
|
||||
}
|
||||
|
||||
fn group(scenario: &str) -> &str {
|
||||
scenario.split(':').next().unwrap_or(scenario)
|
||||
}
|
||||
|
||||
fn collect() -> Report {
|
||||
let mut r = Report { findings: vec![], rows: vec![] };
|
||||
|
||||
// ---- Effects & Presets: closed, everything open, searching, the contents menu.
|
||||
let mut h = harness();
|
||||
h.state_mut().show_panel(PanelKind::EffectsPresets);
|
||||
h.run_steps(4);
|
||||
let rect = panel_rect(&h, PanelKind::EffectsPresets).expect("Effects & Presets is drawn");
|
||||
r.add("EffectsPresets:default", audit(&h, "EffectsPresets:default", rect, None));
|
||||
{
|
||||
let s = h.state_mut();
|
||||
s.session.prefs.toggle_effect_favorite("ec.blur.gaussian");
|
||||
s.ui.effects_open.insert("favorites".into());
|
||||
s.ui.effects_open.insert("presets".into());
|
||||
s.ui.effects_open.insert("presets/Text".into());
|
||||
s.ui.effects_open.insert("Blur & Sharpen".into());
|
||||
}
|
||||
h.run_steps(4);
|
||||
r.add("EffectsPresets:open", audit(&h, "EffectsPresets:open", rect, None));
|
||||
h.state_mut().ui.effects_search = "blur".into();
|
||||
h.run_steps(4);
|
||||
r.add("EffectsPresets:search", audit(&h, "EffectsPresets:search", rect, None));
|
||||
let before = node_ids(&h);
|
||||
h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("fx-contents-menu").with("open"), true));
|
||||
h.run_steps(4);
|
||||
let screen = Rect::from_min_size(pos2(0.0, 0.0), egui::vec2(1600.0, 1000.0));
|
||||
r.add("EffectsPresets:menu", audit(&h, "EffectsPresets:menu", screen, Some(&before)));
|
||||
|
||||
// ---- Settings: every page.
|
||||
let mut h = harness();
|
||||
let before = node_ids(&h);
|
||||
for page in effectcraft_engine::prefs::pages() {
|
||||
invoke(&mut h, "app.settings", json!({"page": page.id}));
|
||||
let name = format!("Settings:{}", page.id);
|
||||
let rect = dialog_rect(&h, "Settings").expect("the Settings dialog is open");
|
||||
r.add(&name, audit(&h, &name, rect, Some(&before)));
|
||||
}
|
||||
|
||||
// ---- Render Queue: empty, queued and expanded, a menu open, the Templates dialogs.
|
||||
let mut h = harness();
|
||||
h.state_mut().show_panel(PanelKind::RenderQueue);
|
||||
h.run_steps(4);
|
||||
let rect = panel_rect(&h, PanelKind::RenderQueue).expect("the Render Queue is drawn");
|
||||
r.add("RenderQueue:empty", audit(&h, "RenderQueue:empty", rect, None));
|
||||
let comp = h.state().session.active_comp_id().expect("a comp").0;
|
||||
let mut items = vec![];
|
||||
for _ in 0..2 {
|
||||
let out = h.state_mut().session.execute("renderQueue.add", json!({"comp": comp})).unwrap();
|
||||
items.push(out["item"].as_u64().or_else(|| out["items"][0].as_u64()).unwrap_or(0));
|
||||
}
|
||||
h.run_steps(4);
|
||||
r.add("RenderQueue:queued", audit(&h, "RenderQueue:queued", rect, None));
|
||||
let first = h.state().session.project.render_queue.first().map(|i| i.id).expect("a queue item");
|
||||
h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("rq-ui"), (vec![first], Some(first))));
|
||||
h.run_steps(4);
|
||||
r.add("RenderQueue:expanded", audit(&h, "RenderQueue:expanded", rect, None));
|
||||
let before = node_ids(&h);
|
||||
h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique(("rq-dd", first, 0)).with("open"), true));
|
||||
h.run_steps(4);
|
||||
r.add("RenderQueue:menu", audit(&h, "RenderQueue:menu", screen, Some(&before)));
|
||||
h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique(("rq-dd", first, 0)).with("open"), false));
|
||||
let before = node_ids(&h);
|
||||
for (kind, title) in [("renderSettings", "Render Settings Templates"), ("outputModule", "Output Module Templates")] {
|
||||
invoke(&mut h, "app.templates", json!({"kind": kind}));
|
||||
let name = format!("RenderQueue:templates:{kind}");
|
||||
let rect = dialog_rect(&h, title).expect("the Templates dialog is open");
|
||||
r.add(&name, audit(&h, &name, rect, Some(&before)));
|
||||
}
|
||||
let _ = items;
|
||||
|
||||
// ---- Everything else, informational: each panel on its own.
|
||||
let mut h = harness();
|
||||
for p in PanelKind::ALL {
|
||||
if matches!(p, PanelKind::EffectsPresets | PanelKind::RenderQueue) {
|
||||
continue;
|
||||
}
|
||||
h.state_mut().show_panel(p);
|
||||
h.run_steps(3);
|
||||
let name = format!("{}:default", p.id());
|
||||
let Some(rect) = panel_rect(&h, p) else {
|
||||
// A panel with a baseline entry must be looked at, or its allowance would pass unchecked.
|
||||
assert!(!BASELINE.iter().any(|(k, ..)| *k == name), "{name} has a BASELINE entry but was not drawn");
|
||||
r.rows.push((format!("{name} (not drawn)"), 0, 0, 0));
|
||||
continue;
|
||||
};
|
||||
r.add(&name, audit(&h, &name, rect, None));
|
||||
if p == PanelKind::Preview {
|
||||
// Closed again so it is not counted against the panels after it (see `harness`).
|
||||
h.state_mut().close_panel(PanelKind::Preview);
|
||||
h.run_steps(3);
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_panels_have_no_unnamed_or_roleless_controls_and_the_others_do_not_get_worse() {
|
||||
let report = collect();
|
||||
println!("\nAccessibility audit: findings per scenario (unnamed in brackets)");
|
||||
let mut by_group: Vec<(String, usize, usize, usize)> = vec![];
|
||||
for (name, n, unnamed, controls) in &report.rows {
|
||||
println!(" {name:<44} {n:>4} findings ({unnamed} unnamed) of {controls} controls");
|
||||
let g = group(name).to_string();
|
||||
match by_group.iter_mut().find(|(k, ..)| *k == g) {
|
||||
Some(e) => {
|
||||
e.1 += n;
|
||||
e.2 += unnamed;
|
||||
e.3 += controls;
|
||||
}
|
||||
None => by_group.push((g, *n, *unnamed, *controls)),
|
||||
}
|
||||
}
|
||||
println!("\nBy panel (findings, unnamed controls, controls looked at):");
|
||||
for (g, n, unnamed, controls) in &by_group {
|
||||
let state = if FIXED.contains(&g.as_str()) { "fixed: must be 0" } else { "not yet audited" };
|
||||
println!(" {g:<28} {n:>4} findings, {unnamed:>3} unnamed, {controls:>4} controls {state}");
|
||||
}
|
||||
let total_unnamed: usize = by_group.iter().map(|(_, _, u, _)| u).sum();
|
||||
println!(" all panels: {total_unnamed} unnamed controls\n");
|
||||
|
||||
let mut failures = vec![];
|
||||
for f in &report.findings {
|
||||
if FIXED.contains(&group(&f.scenario)) {
|
||||
failures.push(f.to_string());
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{} accessibility findings in the fixed panels:\n{}", failures.len(), failures.join("\n"));
|
||||
|
||||
// A clean panel must not be clean because nothing was looked at.
|
||||
for (name, _, _, controls) in &report.rows {
|
||||
if FIXED.contains(&group(name)) {
|
||||
let min = MIN_CONTROLS.iter().find(|(k, _)| k == name).map_or(2, |(_, v)| *v);
|
||||
assert!(*controls >= min, "{name}: only {controls} controls found in the tree (expected {min}); the scenario is not showing the panel");
|
||||
}
|
||||
}
|
||||
|
||||
// The ratchet: a count above its entry is a regression, a count below it must be written back
|
||||
// (else the old, higher allowance would let a later regression through).
|
||||
let mut off = vec![];
|
||||
for (name, n, unnamed, _) in &report.rows {
|
||||
if FIXED.contains(&group(name)) {
|
||||
continue;
|
||||
}
|
||||
let (allowed, allowed_unnamed) = BASELINE.iter().find(|(k, ..)| k == name).map_or((0, 0), |(_, v, u)| (*v, *u));
|
||||
if *n > allowed || *unnamed > allowed_unnamed {
|
||||
off.push(format!("{name}: {n} findings ({unnamed} unnamed), the baseline allows {allowed} ({allowed_unnamed}); a panel must not get worse"));
|
||||
} else if (*n < allowed || *unnamed < allowed_unnamed) && !MACHINE_DEPENDENT.contains(&name.as_str()) {
|
||||
off.push(format!("{name}: improved to {n} findings ({unnamed} unnamed); lower its BASELINE entry to ({n}, {unnamed})"));
|
||||
}
|
||||
}
|
||||
assert!(off.is_empty(), "{} scenarios do not match BASELINE:\n{}", off.len(), off.join("\n"));
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Colour contrast of the theme tokens (docs/gaps.md gap 25), separate from the AccessKit audit:
|
||||
//! the WCAG 2 contrast ratio of the text, accent and focus colours against the backgrounds they
|
||||
//! are drawn on, for every theme. Text needs 4.5:1, the focus ring and control borders 3:1.
|
||||
//!
|
||||
//! Pairs that fall short today are listed in [`KNOWN_BELOW`] with the ratio measured when this was
|
||||
//! written. The list is a ratchet: a pair must not get worse, and a pair that reaches its target
|
||||
//! must leave the list. This checks the tokens, not what a panel really draws with them (text on
|
||||
//! a label colour, an image, a disabled control) and not colour-blindness.
|
||||
|
||||
use effectcraft_ui_egui::theme::{ThemeKind, Tokens};
|
||||
use egui::Color32;
|
||||
|
||||
/// WCAG 2 relative luminance of an sRGB colour.
|
||||
fn luminance(c: Color32) -> f64 {
|
||||
let lin = |v: u8| {
|
||||
let v = f64::from(v) / 255.0;
|
||||
if v <= 0.039_28 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
|
||||
};
|
||||
0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b())
|
||||
}
|
||||
|
||||
/// WCAG 2 contrast ratio of two opaque colours.
|
||||
fn ratio(a: Color32, b: Color32) -> f64 {
|
||||
let (la, lb) = (luminance(a), luminance(b));
|
||||
(la.max(lb) + 0.05) / (la.min(lb) + 0.05)
|
||||
}
|
||||
|
||||
/// (name, foreground, background, minimum ratio)
|
||||
fn pairs(t: &Tokens) -> Vec<(&'static str, Color32, Color32, f64)> {
|
||||
vec![
|
||||
("text on panel", t.text, t.panel_bg, 4.5),
|
||||
("text on field", t.text, t.field_bg, 4.5),
|
||||
("text on selected row", t.text, t.row_selected, 4.5),
|
||||
("text on row", t.text, t.row, 4.5),
|
||||
("dim text on panel", t.text_dim, t.panel_bg, 4.5),
|
||||
("faint text on panel", t.text_faint, t.panel_bg, 4.5),
|
||||
("active tab text on panel", t.tab_text_active, t.panel_bg, 4.5),
|
||||
("tab text on header", t.tab_text, t.header_bg, 4.5),
|
||||
("hot text on panel", t.hot_text, t.panel_bg, 4.5),
|
||||
("white on accent (primary button)", Color32::WHITE, t.accent, 4.5),
|
||||
("danger text on panel", t.danger, t.panel_bg, 4.5),
|
||||
("warning text on panel", t.warning, t.panel_bg, 4.5),
|
||||
("accent (focus ring) on panel", t.accent, t.panel_bg, 3.0),
|
||||
("focus colour on panel", t.focus, t.panel_bg, 3.0),
|
||||
("field border on panel", t.field_border, t.panel_bg, 3.0),
|
||||
("icon on panel", t.icon, t.panel_bg, 3.0),
|
||||
]
|
||||
}
|
||||
|
||||
/// (theme, pair, ratio measured when written). Ratchet: never below the number; remove the entry
|
||||
/// when the pair reaches its target.
|
||||
const KNOWN_BELOW: &[(&str, &str, f64)] = &[
|
||||
("dark", "white on accent (primary button)", 3.46),
|
||||
("dark", "danger text on panel", 3.90),
|
||||
("dark", "field border on panel", 1.38),
|
||||
("darker", "white on accent (primary button)", 3.46),
|
||||
("darker", "danger text on panel", 4.37),
|
||||
("darker", "field border on panel", 1.55),
|
||||
("light", "faint text on panel", 4.46),
|
||||
("light", "white on accent (primary button)", 3.46),
|
||||
("light", "danger text on panel", 3.23),
|
||||
("light", "warning text on panel", 1.85),
|
||||
("light", "accent (focus ring) on panel", 2.78),
|
||||
("light", "focus colour on panel", 2.78),
|
||||
("light", "field border on panel", 1.86),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn print_and_hold_the_theme_contrast() {
|
||||
let mut failures = vec![];
|
||||
for kind in [ThemeKind::Dark, ThemeKind::Darker, ThemeKind::Light] {
|
||||
let t = Tokens::for_kind(kind);
|
||||
for (name, fg, bg, min) in pairs(&t) {
|
||||
let r = ratio(fg, bg);
|
||||
let known = KNOWN_BELOW.iter().find(|(k, n, _)| *k == kind.name() && *n == name);
|
||||
println!("{:<7} {name:<36} {r:>5.2}:1 (needs {min})", kind.name());
|
||||
match known {
|
||||
Some((_, _, floor)) => {
|
||||
if r + 0.005 < *floor {
|
||||
failures.push(format!("{} / {name}: {r:.2}:1 is worse than the known {floor:.2}:1", kind.name()));
|
||||
}
|
||||
if r >= min {
|
||||
failures.push(format!("{} / {name}: {r:.2}:1 now reaches {min}:1; remove it from KNOWN_BELOW", kind.name()));
|
||||
}
|
||||
}
|
||||
None if r < min => failures.push(format!("{} / {name}: {r:.2}:1 is below {min}:1", kind.name())),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
//! Effects & Presets keyboard operation and accessible names (docs/gaps.md gap 25): the list is one
|
||||
//! Tab stop; the arrow keys, Home / End, Right / Left, Enter and Space work it; the focus follows
|
||||
//! the cursor; rows are tree items with a name, a selected state, a depth and (folders) an open
|
||||
//! state; the favourite star is a named button.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::dock::PanelKind;
|
||||
use egui::accesskit::Role;
|
||||
use egui::{Event, Key, Modifiers, Pos2, pos2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use serde_json::json;
|
||||
|
||||
fn harness() -> Harness<'static, EffectcraftApp> {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Red", "color": "#c04040"})).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.state_mut().show_panel(PanelKind::EffectsPresets);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 {
|
||||
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone();
|
||||
pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
|
||||
let p = center(h, id);
|
||||
h.input_mut().events.push(Event::PointerMoved(p));
|
||||
h.step();
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
}
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) {
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers });
|
||||
h.step();
|
||||
}
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
/// The role and name of the node that has the keyboard focus.
|
||||
fn focused(h: &Harness<'_, EffectcraftApp>) -> Option<(Role, String)> {
|
||||
h.root().children_recursive().find(|n| n.is_focused()).map(|n| (n.accesskit_node().role(), n.accesskit_node().label().unwrap_or_default()))
|
||||
}
|
||||
|
||||
fn selected_rows(h: &Harness<'_, EffectcraftApp>) -> Vec<String> {
|
||||
let panel = h.state().auto.find("panel.EffectsPresets").expect("the panel is drawn").rect;
|
||||
let panel = egui::Rect::from_min_size(pos2(panel[0], panel[1]), egui::vec2(panel[2], panel[3]));
|
||||
h.root()
|
||||
.children_recursive()
|
||||
.filter(|n| n.accesskit_node().role() == Role::TreeItem && n.accesskit_node().is_selected() == Some(true))
|
||||
.filter(|n| n.accesskit_node().bounding_box().is_some_and(|b| panel.contains(pos2(b.x0 as f32 + 1.0, b.y0 as f32 + 1.0))))
|
||||
.map(|n| n.accesskit_node().label().unwrap_or_default())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Put the keyboard on the first row the way Tab does: from the search field, the menu button,
|
||||
/// then the list.
|
||||
fn tab_into_list(h: &mut Harness<'_, EffectcraftApp>) {
|
||||
h.get_by_label("Search effects and presets").focus();
|
||||
h.run_steps(3);
|
||||
key(h, Key::Tab, Modifiers::NONE);
|
||||
assert_eq!(focused(h).map(|f| f.1), Some("Effects & Presets options".to_string()), "Tab leaves the search field for the options button");
|
||||
key(h, Key::Tab, Modifiers::NONE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_reaches_the_list_once_and_leaves_it() {
|
||||
let mut h = harness();
|
||||
tab_into_list(&mut h);
|
||||
let (role, name) = focused(&h).expect("a row has the focus");
|
||||
assert_eq!(role, Role::TreeItem);
|
||||
assert!(!name.is_empty());
|
||||
// The first row is a folder: no star. The next Tab leaves the list instead of walking the
|
||||
// hundreds of effects.
|
||||
key(&mut h, Key::Tab, Modifiers::NONE);
|
||||
let after = focused(&h);
|
||||
assert!(after.as_ref().is_none_or(|(r, _)| *r != Role::TreeItem), "the second Tab is past the list: {after:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrows_home_end_move_the_cursor_and_the_focus() {
|
||||
let mut h = harness();
|
||||
tab_into_list(&mut h);
|
||||
let first = focused(&h).unwrap().1;
|
||||
assert_eq!(selected_rows(&h), Vec::<String>::new(), "Tab alone selects nothing");
|
||||
key(&mut h, Key::ArrowDown, Modifiers::NONE);
|
||||
let second = focused(&h).unwrap().1;
|
||||
assert_ne!(first, second, "Down Arrow moves to the next row");
|
||||
assert_eq!(selected_rows(&h), vec![second.clone()], "the row the cursor is on is selected");
|
||||
key(&mut h, Key::ArrowUp, Modifiers::NONE);
|
||||
assert_eq!(focused(&h).unwrap().1, first);
|
||||
key(&mut h, Key::End, Modifiers::NONE);
|
||||
let last = focused(&h).unwrap().1;
|
||||
assert_ne!(last, first, "End goes to the last row");
|
||||
key(&mut h, Key::ArrowDown, Modifiers::NONE);
|
||||
assert_eq!(focused(&h).unwrap().1, last, "Down Arrow stays on the last row");
|
||||
key(&mut h, Key::Home, Modifiers::NONE);
|
||||
assert_eq!(focused(&h).unwrap().1, first, "Home goes to the first row");
|
||||
key(&mut h, Key::PageDown, Modifiers::NONE);
|
||||
assert_ne!(focused(&h).unwrap().1, first, "Page Down moves a page");
|
||||
// The cursor row is scrolled into view: its node is on screen.
|
||||
let name = focused(&h).unwrap().1;
|
||||
let r = h.get_by_label(&name).rect();
|
||||
assert!(r.min.y >= 0.0 && r.max.y <= 1000.0, "the focused row is on screen: {r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_and_left_open_and_close_folders_and_step_through_the_tree() {
|
||||
let mut h = harness();
|
||||
h.state_mut().ui.effects_search.clear();
|
||||
tab_into_list(&mut h);
|
||||
// Walk to a category folder (not the presets folder).
|
||||
let mut guard = 0;
|
||||
loop {
|
||||
let n = focused(&h).unwrap().1;
|
||||
if n == "Blur & Sharpen" {
|
||||
break;
|
||||
}
|
||||
key(&mut h, Key::ArrowDown, Modifiers::NONE);
|
||||
guard += 1;
|
||||
assert!(guard < 40, "no Blur & Sharpen folder reached");
|
||||
}
|
||||
let folder = h.get_by_label("Blur & Sharpen");
|
||||
assert_eq!(folder.accesskit_node().data().is_expanded(), Some(false));
|
||||
assert_eq!(folder.accesskit_node().data().level(), Some(1));
|
||||
key(&mut h, Key::ArrowRight, Modifiers::NONE);
|
||||
assert!(h.state().ui.effects_open.contains("Blur & Sharpen"), "Right Arrow opens the folder");
|
||||
assert_eq!(h.get_by_label("Blur & Sharpen").accesskit_node().data().is_expanded(), Some(true));
|
||||
key(&mut h, Key::ArrowRight, Modifiers::NONE);
|
||||
let child = focused(&h).unwrap();
|
||||
assert_eq!(child.0, Role::TreeItem);
|
||||
assert_ne!(child.1, "Blur & Sharpen", "Right Arrow on an open folder steps into it");
|
||||
assert_eq!(h.get_by_label(&child.1).accesskit_node().data().level(), Some(2));
|
||||
key(&mut h, Key::ArrowLeft, Modifiers::NONE);
|
||||
assert_eq!(focused(&h).unwrap().1, "Blur & Sharpen", "Left Arrow on an effect steps out to its folder");
|
||||
key(&mut h, Key::ArrowLeft, Modifiers::NONE);
|
||||
assert!(!h.state().ui.effects_open.contains("Blur & Sharpen"), "Left Arrow closes the folder");
|
||||
key(&mut h, Key::Enter, Modifiers::NONE);
|
||||
assert!(h.state().ui.effects_open.contains("Blur & Sharpen"), "Enter opens a closed folder");
|
||||
key(&mut h, Key::Space, Modifiers::NONE);
|
||||
assert!(!h.state().ui.effects_open.contains("Blur & Sharpen"), "Space closes it again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_and_space_apply_an_effect_to_the_selected_layer() {
|
||||
for apply_key in [Key::Enter, Key::Space] {
|
||||
let mut h = harness();
|
||||
tab_into_list(&mut h);
|
||||
h.state_mut().ui.effects_open.insert("Blur & Sharpen".into());
|
||||
h.run_steps(3);
|
||||
let mut guard = 0;
|
||||
while focused(&h).map(|f| f.1) != Some("Blur & Sharpen".to_string()) {
|
||||
key(&mut h, Key::ArrowDown, Modifiers::NONE);
|
||||
guard += 1;
|
||||
assert!(guard < 40);
|
||||
}
|
||||
key(&mut h, Key::ArrowDown, Modifiers::NONE);
|
||||
let effect = focused(&h).unwrap().1;
|
||||
assert!(h.state().session.state.last_effect.is_none());
|
||||
key(&mut h, apply_key, Modifiers::NONE);
|
||||
let applied = h.state().session.state.last_effect.clone();
|
||||
assert!(applied.is_some(), "{apply_key:?} on `{effect}` applies it to the selected layer");
|
||||
let pb = &h.state().playback;
|
||||
assert!(!pb.playing && !pb.caching && pb.plan.is_none(), "{apply_key:?} on a focused row is the row's, it does not start a preview");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_star_is_a_named_button_on_the_cursor_row_and_enter_toggles_it() {
|
||||
let mut h = harness();
|
||||
tab_into_list(&mut h);
|
||||
h.state_mut().ui.effects_open.insert("Blur & Sharpen".into());
|
||||
h.run_steps(3);
|
||||
let mut guard = 0;
|
||||
while focused(&h).map(|f| f.1) != Some("Blur & Sharpen".to_string()) {
|
||||
key(&mut h, Key::ArrowDown, Modifiers::NONE);
|
||||
guard += 1;
|
||||
assert!(guard < 40, "no Blur & Sharpen folder reached");
|
||||
}
|
||||
key(&mut h, Key::ArrowDown, Modifiers::NONE);
|
||||
let effect = focused(&h).unwrap().1;
|
||||
key(&mut h, Key::Tab, Modifiers::NONE);
|
||||
let (role, name) = focused(&h).expect("the star has the focus");
|
||||
assert_eq!(role, Role::Button);
|
||||
assert_eq!(name, format!("Add to favorites: {effect}"));
|
||||
key(&mut h, Key::Enter, Modifiers::NONE);
|
||||
assert_eq!(h.state().session.prefs.effects_favorites.len(), 1, "Enter on the star adds the favourite");
|
||||
let (_, name) = focused(&h).expect("the star keeps the focus");
|
||||
assert_eq!(name, format!("Remove from favorites: {effect}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_search_field_keeps_its_arrows_and_the_layer_does_not_nudge() {
|
||||
let mut h = harness();
|
||||
h.get_by_label("Search effects and presets").focus();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Search effects and presets").type_text("blur");
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().ui.effects_search, "blur");
|
||||
key(&mut h, Key::ArrowDown, Modifiers::NONE);
|
||||
assert!(selected_rows(&h).is_empty(), "arrows in the search field belong to the field");
|
||||
// A click on a row, then the arrows: the list moves, the selected layer stays where it is.
|
||||
let layer_before = layer_position(&h);
|
||||
h.state_mut().ui.effects_search.clear();
|
||||
h.run_steps(3);
|
||||
click(&mut h, "effects.category.presets");
|
||||
assert_eq!(h.state().ui.focused, PanelKind::EffectsPresets);
|
||||
key(&mut h, Key::ArrowDown, Modifiers::NONE);
|
||||
assert_eq!(selected_rows(&h).len(), 1, "the arrows work after a click, without keyboard focus on a row");
|
||||
key(&mut h, Key::ArrowRight, Modifiers::NONE);
|
||||
assert_eq!(layer_position(&h), layer_before, "the arrow keys did not nudge the selected layer");
|
||||
}
|
||||
|
||||
fn layer_position(h: &Harness<'_, EffectcraftApp>) -> String {
|
||||
let s = &h.state().session;
|
||||
let comp = s.active_comp().expect("a comp");
|
||||
let layer = comp.layers.first().expect("a layer");
|
||||
format!("{:?}", layer.transform())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_and_controls_have_names_roles_and_states() {
|
||||
let mut h = harness();
|
||||
h.state_mut().ui.effects_open.insert("Blur & Sharpen".into());
|
||||
h.state_mut().session.prefs.toggle_effect_favorite("ec.blur.gaussian");
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.get_by_label("Search effects and presets").accesskit_node().role(), Role::TextInput);
|
||||
let menu = h.get_by_label("Effects & Presets options");
|
||||
assert_eq!(menu.accesskit_node().role(), Role::Button);
|
||||
assert_eq!(menu.accesskit_node().data().is_expanded(), Some(false), "the options button says its menu is closed");
|
||||
let folder = h.get_by_label("Blur & Sharpen");
|
||||
assert_eq!(folder.accesskit_node().role(), Role::TreeItem);
|
||||
assert_eq!(folder.accesskit_node().data().is_expanded(), Some(true));
|
||||
assert_eq!(folder.accesskit_node().is_selected(), Some(false));
|
||||
let favorites = h.get_by_label("Favorites");
|
||||
assert_eq!(favorites.accesskit_node().data().is_expanded(), Some(false));
|
||||
// Every effect row in an open category is a leaf at depth 2.
|
||||
let leaves: Vec<_> =
|
||||
h.root().children_recursive().filter(|n| n.accesskit_node().role() == Role::TreeItem && n.accesskit_node().data().level() == Some(2)).collect();
|
||||
assert!(!leaves.is_empty());
|
||||
assert!(leaves.iter().all(|n| n.accesskit_node().data().is_expanded().is_none()), "effects are leaves: they have no open state");
|
||||
let gaussian_rows = h
|
||||
.root()
|
||||
.children_recursive()
|
||||
.filter(|n| n.accesskit_node().role() == Role::TreeItem && n.accesskit_node().label().as_deref() == Some("Gaussian Blur"))
|
||||
.count();
|
||||
assert!(gaussian_rows <= 1, "a favourite hidden in a closed Favorites folder draws no rows");
|
||||
assert!(h.query_all_by_label_contains("Add to favorites: ").count() > 1, "the effects' stars are named");
|
||||
}
|
||||
|
||||
/// A pick from the options menu with the mouse leaves no widget with the keyboard focus, and Enter
|
||||
/// after a click in the panel is the panel's: it opens or applies the row and does not start the
|
||||
/// layer rename the Timeline binds to Enter.
|
||||
#[test]
|
||||
fn mouse_picks_leave_no_focus_and_enter_after_a_click_is_the_panels() {
|
||||
let mut h = harness();
|
||||
click(&mut h, "effects.menu");
|
||||
assert!(h.ctx.memory(|m| m.focused()).is_none(), "opening the menu with the mouse takes no keyboard focus");
|
||||
h.get_by_label_contains("Alphabetical").click();
|
||||
h.run_steps(4);
|
||||
assert!(h.ctx.memory(|m| m.focused()).is_none(), "choosing an entry with the mouse leaves no keyboard focus");
|
||||
assert!(h.state().ui.effects_view.alphabetical);
|
||||
h.state_mut().ui.effects_view.alphabetical = false;
|
||||
h.run_steps(3);
|
||||
click(&mut h, "effects.category.presets");
|
||||
let open = h.state().ui.effects_open.contains("presets");
|
||||
key(&mut h, Key::Enter, Modifiers::NONE);
|
||||
assert_ne!(h.state().ui.effects_open.contains("presets"), open, "Enter toggles the clicked folder");
|
||||
assert!(h.state().auto.query("timeline.layer.").iter().all(|e| !e.id.ends_with(".nameEdit")), "no layer rename started");
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Render Queue keyboard operation and accessible names (docs/gaps.md gap 25): queue items are
|
||||
//! tree items (named by number, comp and status; selected; open or closed); the arrow keys, Home /
|
||||
//! End, Right / Left, Enter and Delete work them; the buttons, the check boxes, the twirls and the
|
||||
//! drop-downs have names and states; a drop-down opens from the keyboard and closes with the focus
|
||||
//! back on it; the Templates dialog is named.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::dock::PanelKind;
|
||||
use egui::accesskit::Role;
|
||||
use egui::{Event, Key, Modifiers, Pos2, pos2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use serde_json::json;
|
||||
|
||||
fn harness() -> Harness<'static, EffectcraftApp> {
|
||||
let mut s = Session::default();
|
||||
for name in ["Alpha", "Beta", "Gamma"] {
|
||||
let comp = s.execute("comp.new", json!({"name": name, "width": 320, "height": 180, "duration": 4})).unwrap()["comp"].as_u64().unwrap();
|
||||
s.execute("renderQueue.add", json!({"comp": comp})).unwrap();
|
||||
}
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.state_mut().show_panel(PanelKind::RenderQueue);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) {
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers });
|
||||
h.step();
|
||||
}
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 {
|
||||
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone();
|
||||
pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
|
||||
let p = center(h, id);
|
||||
h.input_mut().events.push(Event::PointerMoved(p));
|
||||
h.step();
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
}
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn focused(h: &Harness<'_, EffectcraftApp>) -> Option<(Role, String)> {
|
||||
h.root().children_recursive().find(|n| n.is_focused()).map(|n| (n.accesskit_node().role(), n.accesskit_node().label().unwrap_or_default()))
|
||||
}
|
||||
|
||||
fn queue_len(h: &Harness<'_, EffectcraftApp>) -> usize {
|
||||
h.state().session.project.render_queue.len()
|
||||
}
|
||||
|
||||
fn row_name(h: &Harness<'_, EffectcraftApp>, n: usize) -> String {
|
||||
h.query_all_by_role(Role::TreeItem).map(|r| r.accesskit_node().label().unwrap_or_default()).find(|l| l.starts_with(&format!("{n}. "))).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_are_named_tree_items_with_selected_and_open_state() {
|
||||
let mut h = harness();
|
||||
let first = row_name(&h, 1);
|
||||
assert!(first.starts_with("1. Alpha, "), "number, comp and status: {first}");
|
||||
let node = h.get_by_role_and_label(Role::TreeItem, &first);
|
||||
assert_eq!(node.accesskit_node().is_selected(), Some(false));
|
||||
assert_eq!(node.accesskit_node().data().is_expanded(), Some(false));
|
||||
assert_eq!(node.accesskit_node().data().level(), Some(1));
|
||||
click(&mut h, "renderQueue.item.1.twirl");
|
||||
let node = h.get_by_role_and_label(Role::TreeItem, &first);
|
||||
assert_eq!(node.accesskit_node().data().is_expanded(), Some(true), "the open item says so");
|
||||
let twirl = h.get_by_label("Hide details of Alpha");
|
||||
assert_eq!(twirl.accesskit_node().role(), Role::Button);
|
||||
assert_eq!(twirl.accesskit_node().data().is_expanded(), Some(true));
|
||||
// The settings drop-downs of the open item: what they set and what they are set to.
|
||||
let settings = h.get_by_role_and_label(Role::ComboBox, "Render Settings");
|
||||
assert!(settings.accesskit_node().value().is_some_and(|v| !v.is_empty()));
|
||||
assert!(h.query_by_role_and_label(Role::ComboBox, "Output Module").is_some());
|
||||
assert!(h.query_by_role_and_label(Role::ComboBox, "Log").is_some());
|
||||
assert!(h.query_by_role_and_label(Role::ComboBox, "Output To").is_some());
|
||||
assert!(h.query_by_role_and_label(Role::Button, "Render").is_some());
|
||||
let notify = h.get_by_role_and_label(Role::CheckBox, "Notify");
|
||||
assert!(notify.accesskit_node().toggled().is_some());
|
||||
let render = h.get_by_role_and_label(Role::CheckBox, "Render: Alpha");
|
||||
assert_eq!(render.accesskit_node().toggled(), Some(egui::accesskit::Toggled::True));
|
||||
assert_eq!(h.get_by_role(Role::ProgressIndicator).accesskit_node().data().numeric_value(), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrows_home_end_right_left_enter_and_delete_work_the_rows() {
|
||||
let mut h = harness();
|
||||
h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).focus();
|
||||
h.run_steps(3);
|
||||
assert_eq!(focused(&h).map(|f| f.1), Some(row_name(&h, 1)));
|
||||
let selected = |h: &Harness<'_, EffectcraftApp>| {
|
||||
h.query_all_by_role(Role::TreeItem)
|
||||
.filter(|n| n.accesskit_node().is_selected() == Some(true))
|
||||
.map(|n| n.accesskit_node().label().unwrap_or_default())
|
||||
.filter(|l| l.starts_with(|c: char| c.is_ascii_digit()))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(selected(&h), vec![row_name(&h, 1)], "a focused row is selected");
|
||||
key(&mut h, Key::ArrowDown, Modifiers::NONE);
|
||||
assert_eq!(focused(&h).map(|f| f.1), Some(row_name(&h, 2)), "Down Arrow moves to the next item and the focus goes with it");
|
||||
assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 2)).accesskit_node().is_selected(), Some(true));
|
||||
key(&mut h, Key::End, Modifiers::NONE);
|
||||
assert_eq!(focused(&h).map(|f| f.1), Some(row_name(&h, 3)));
|
||||
key(&mut h, Key::ArrowDown, Modifiers::NONE);
|
||||
assert_eq!(focused(&h).map(|f| f.1), Some(row_name(&h, 3)), "Down Arrow stays on the last item");
|
||||
key(&mut h, Key::Home, Modifiers::NONE);
|
||||
assert_eq!(focused(&h).map(|f| f.1), Some(row_name(&h, 1)));
|
||||
key(&mut h, Key::ArrowRight, Modifiers::NONE);
|
||||
assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).accesskit_node().data().is_expanded(), Some(true), "Right Arrow opens the item");
|
||||
key(&mut h, Key::ArrowLeft, Modifiers::NONE);
|
||||
assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).accesskit_node().data().is_expanded(), Some(false), "Left Arrow closes it");
|
||||
key(&mut h, Key::Enter, Modifiers::NONE);
|
||||
assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).accesskit_node().data().is_expanded(), Some(true), "Enter toggles it");
|
||||
key(&mut h, Key::Space, Modifiers::NONE);
|
||||
assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).accesskit_node().data().is_expanded(), Some(false), "Space toggles it back");
|
||||
assert!(!h.state().playback.playing && !h.state().playback.caching && h.state().playback.plan.is_none(), "Space on a row does not start a preview");
|
||||
// Delete removes the selected item, as it does with the mouse's selection.
|
||||
key(&mut h, Key::ArrowDown, Modifiers::NONE);
|
||||
assert_eq!(queue_len(&h), 3);
|
||||
key(&mut h, Key::Delete, Modifiers::NONE);
|
||||
assert_eq!(queue_len(&h), 2, "Delete removes the selected item");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_reaches_every_control_of_a_row() {
|
||||
let mut h = harness();
|
||||
h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).focus();
|
||||
h.run_steps(3);
|
||||
let mut seen = vec![focused(&h).unwrap()];
|
||||
for _ in 0..40 {
|
||||
key(&mut h, Key::Tab, Modifiers::NONE);
|
||||
match focused(&h) {
|
||||
Some(f) if !seen.contains(&f) => seen.push(f),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let names: Vec<&str> = seen.iter().map(|s| s.1.as_str()).collect();
|
||||
for want in ["Show details of Alpha", "Render: Alpha", "Show details of Beta", "Render: Beta"] {
|
||||
assert!(names.contains(&want), "Tab reaches `{want}`; reached {names:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drop_down_opens_from_the_keyboard_and_gives_the_focus_back() {
|
||||
let mut h = harness();
|
||||
click(&mut h, "renderQueue.item.1.twirl");
|
||||
h.get_by_role_and_label(Role::ComboBox, "Render Settings").focus();
|
||||
h.run_steps(3);
|
||||
key(&mut h, Key::Enter, Modifiers::NONE);
|
||||
let (role, first_entry) = focused(&h).expect("the first entry has the focus");
|
||||
assert_ne!(role, Role::ComboBox, "the keyboard is in the menu: {first_entry}");
|
||||
key(&mut h, Key::ArrowDown, Modifiers::NONE);
|
||||
let second = focused(&h).expect("Down Arrow moves to the next entry").1;
|
||||
assert_ne!(second, first_entry);
|
||||
key(&mut h, Key::ArrowUp, Modifiers::NONE);
|
||||
assert_eq!(focused(&h).map(|f| f.1), Some(first_entry), "Up Arrow moves back");
|
||||
key(&mut h, Key::Enter, Modifiers::NONE);
|
||||
assert_eq!(focused(&h).map(|f| f.0), Some(Role::ComboBox), "choosing an entry gives the focus back to the drop-down");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_check_box_and_the_buttons_work_from_the_keyboard() {
|
||||
let mut h = harness();
|
||||
let before = h.state().session.project.render_queue.first().map(|i| i.render);
|
||||
h.get_by_role_and_label(Role::CheckBox, "Render: Alpha").focus();
|
||||
h.run_steps(3);
|
||||
key(&mut h, Key::Space, Modifiers::NONE);
|
||||
assert_ne!(h.state().session.project.render_queue.first().map(|i| i.render), before, "Space toggles the Render check box");
|
||||
let notify = h.state().session.project.render_prefs.notify;
|
||||
h.get_by_role_and_label(Role::CheckBox, "Notify").focus();
|
||||
h.run_steps(3);
|
||||
key(&mut h, Key::Enter, Modifiers::NONE);
|
||||
assert_ne!(h.state().session.project.render_prefs.notify, notify, "Enter toggles Notify");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_templates_dialog_is_named() {
|
||||
let mut h = harness();
|
||||
let ctx = h.ctx.clone();
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "app.templates", json!({"kind": "renderSettings"})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert!(h.query_by_role_and_label(Role::ComboBox, "Settings Name").is_some());
|
||||
assert!(h.query_by_role_and_label(Role::TextInput, "template name").is_some());
|
||||
for name in ["Duplicate", "Delete", " OK "] {
|
||||
assert_eq!(h.get_by_role_and_label(Role::Button, name).accesskit_node().role(), Role::Button, "{name}");
|
||||
}
|
||||
let defaults = h.query_all_by_role(Role::ComboBox).count();
|
||||
assert!(defaults >= 4, "the default slots are drop-downs: {defaults}");
|
||||
}
|
||||
|
||||
/// A pick with the mouse leaves no widget with the keyboard focus: egui pauses the application's
|
||||
/// shortcuts (Space, Ctrl+Z) while one has it (#776's rule for the Project panel).
|
||||
#[test]
|
||||
fn a_pick_with_the_mouse_leaves_no_keyboard_focus() {
|
||||
let mut h = harness();
|
||||
click(&mut h, "renderQueue.item.1.twirl");
|
||||
click(&mut h, "renderQueue.item.1.renderSettings");
|
||||
assert!(h.ctx.memory(|m| m.focused()).is_none(), "opening a drop-down with the mouse takes no keyboard focus");
|
||||
let template = h.state().session.project.render_templates.render_settings_list()[0].name.clone();
|
||||
h.get_by_label_contains(&template).click();
|
||||
h.run_steps(4);
|
||||
assert!(h.ctx.memory(|m| m.focused()).is_none(), "choosing an entry with the mouse leaves no keyboard focus");
|
||||
// The Effects & Presets options menu the same.
|
||||
}
|
||||
|
||||
/// Enter after a click in the panel belongs to the panel (it opens or applies the row), not to
|
||||
/// the layer rename the Timeline binds to it.
|
||||
#[test]
|
||||
fn enter_after_a_click_opens_the_item_and_does_not_rename_a_layer() {
|
||||
let mut h = harness();
|
||||
h.state_mut().session.execute("layer.newSolid", json!({"name": "Red", "color": "#c04040"})).unwrap();
|
||||
h.run_steps(3);
|
||||
click(&mut h, "renderQueue.item.1.row");
|
||||
assert_eq!(h.state().ui.focused, PanelKind::RenderQueue);
|
||||
key(&mut h, Key::Enter, Modifiers::NONE);
|
||||
assert_eq!(h.get_by_role_and_label(Role::TreeItem, &row_name(&h, 1)).accesskit_node().data().is_expanded(), Some(true), "Enter opens the clicked item");
|
||||
assert!(h.state().auto.query("timeline.layer.").iter().all(|e| !e.id.ends_with(".nameEdit")), "no layer rename started");
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//! Settings keyboard operation and accessible names (docs/gaps.md gap 25): the page list is one
|
||||
//! Tab stop worked with the arrow keys, Home and End; every control on a page has the name its
|
||||
//! label shows, with its value; Escape still cancels.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use egui::accesskit::{Role, Toggled};
|
||||
use egui::{Event, Key, Modifiers};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use serde_json::json;
|
||||
|
||||
fn harness(page: &str) -> Harness<'static, EffectcraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(Session::default()));
|
||||
h.run_steps(3);
|
||||
let ctx = h.ctx.clone();
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "app.settings", json!({"page": page})).unwrap();
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) {
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers });
|
||||
h.step();
|
||||
}
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn focused(h: &Harness<'_, EffectcraftApp>) -> Option<(Role, String)> {
|
||||
h.root().children_recursive().find(|n| n.is_focused()).map(|n| (n.accesskit_node().role(), n.accesskit_node().label().unwrap_or_default()))
|
||||
}
|
||||
|
||||
fn titles() -> Vec<&'static str> {
|
||||
effectcraft_engine::prefs::pages().iter().map(|p| p.title).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_page_list_is_one_tab_stop_worked_with_the_arrows() {
|
||||
let mut h = harness("general");
|
||||
let titles = titles();
|
||||
let n = titles.len();
|
||||
// Every page is a list item: its place in the list, and the open page selected.
|
||||
for (i, title) in titles.iter().enumerate() {
|
||||
let item = h.get_by_role_and_label(Role::ListItem, title);
|
||||
assert_eq!(item.accesskit_node().role(), Role::ListItem, "{title}");
|
||||
assert_eq!(item.accesskit_node().data().position_in_set(), Some(i + 1));
|
||||
assert_eq!(item.accesskit_node().data().size_of_set(), Some(n));
|
||||
assert_eq!(item.accesskit_node().is_selected(), Some(i == 0), "{title}");
|
||||
}
|
||||
h.get_by_role_and_label(Role::ListItem, titles[0]).focus();
|
||||
h.run_steps(3);
|
||||
assert_eq!(focused(&h), Some((Role::ListItem, titles[0].to_string())));
|
||||
key(&mut h, Key::ArrowDown, Modifiers::NONE);
|
||||
assert_eq!(focused(&h), Some((Role::ListItem, titles[1].to_string())), "Down Arrow opens the next page and the focus goes with it");
|
||||
assert_eq!(h.get_by_role_and_label(Role::ListItem, titles[1]).accesskit_node().is_selected(), Some(true));
|
||||
assert_eq!(h.get_by_role_and_label(Role::ListItem, titles[0]).accesskit_node().is_selected(), Some(false));
|
||||
key(&mut h, Key::ArrowUp, Modifiers::NONE);
|
||||
assert_eq!(focused(&h).map(|f| f.1), Some(titles[0].to_string()));
|
||||
key(&mut h, Key::ArrowUp, Modifiers::NONE);
|
||||
assert_eq!(focused(&h).map(|f| f.1), Some(titles[0].to_string()), "Up Arrow stops at the first page");
|
||||
key(&mut h, Key::End, Modifiers::NONE);
|
||||
assert_eq!(focused(&h).map(|f| f.1), Some(titles[n - 1].to_string()), "End goes to the last page");
|
||||
key(&mut h, Key::Home, Modifiers::NONE);
|
||||
assert_eq!(focused(&h).map(|f| f.1), Some(titles[0].to_string()), "Home goes to the first page");
|
||||
// Only the open page is a Tab stop: Tab leaves the list for the page's first control.
|
||||
key(&mut h, Key::Tab, Modifiers::NONE);
|
||||
let (role, _) = focused(&h).expect("Tab moves the focus on");
|
||||
assert_ne!(role, Role::ListItem, "Tab leaves the page list");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controls_have_the_name_their_label_shows_and_their_value() {
|
||||
let h = harness("general");
|
||||
let undo = h.get_by_label("Levels of Undo");
|
||||
assert_eq!(undo.accesskit_node().role(), Role::SpinButton);
|
||||
assert!(undo.accesskit_node().numeric_value().is_some(), "a spin button carries its number");
|
||||
let language = h.get_by_label("Language");
|
||||
assert_eq!(language.accesskit_node().role(), Role::ComboBox);
|
||||
assert!(language.accesskit_node().value().is_some_and(|v| !v.is_empty()), "a drop-down carries what it is set to");
|
||||
let tips = h.get_by_label("Show Tool Tips");
|
||||
assert_eq!(tips.accesskit_node().role(), Role::CheckBox);
|
||||
assert!(matches!(tips.accesskit_node().toggled(), Some(Toggled::True | Toggled::False)));
|
||||
for name in [" OK ", "Cancel", "Previous", "Next"] {
|
||||
assert_eq!(h.get_by_label(name).accesskit_node().role(), Role::Button, "{name}");
|
||||
}
|
||||
// Previous is disabled on the first page.
|
||||
assert!(h.get_by_label("Previous").accesskit_node().is_disabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_fields_colour_buttons_and_theme_cards_are_named() {
|
||||
let h = harness("labels");
|
||||
let name = h.get_by_label("Label 1 name");
|
||||
assert_eq!(name.accesskit_node().role(), Role::TextInput);
|
||||
assert!(name.accesskit_node().value().is_some_and(|v| !v.is_empty()), "the field carries its text");
|
||||
let color = h.get_by_label("Label 1 color");
|
||||
assert_eq!(color.accesskit_node().role(), Role::ColorWell);
|
||||
assert!(color.accesskit_node().value().is_some_and(|v| v.starts_with('#')), "a colour carries its hex value");
|
||||
let h = harness("appearance");
|
||||
assert_eq!(h.get_by_label("Appearance Mode").accesskit_node().role(), Role::ComboBox);
|
||||
assert!(h.query_all_by_role(Role::RadioButton).count() >= 2, "the themes are radio buttons");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_still_cancels_with_the_keyboard_on_the_page_list() {
|
||||
let mut h = harness("general");
|
||||
let first = titles()[0];
|
||||
h.get_by_role_and_label(Role::ListItem, first).focus();
|
||||
h.run_steps(3);
|
||||
key(&mut h, Key::Escape, Modifiers::NONE);
|
||||
assert!(h.state().dialog.is_none(), "Escape closes the dialog");
|
||||
}
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
# Where EffectCraft falls short
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (path-editing layer bounds, #1220; issue, PR and catalog references gap 21: percentage guides and effect colour labels in; earlier change: re-audited against `upstream/main` 470ea7a3; ranked gap list from the 10 October re-measure otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (gap 25: an AccessKit audit test and a theme contrast test, Effects & Presets, Settings and Render Queue named and keyboard-operable, #1335; earlier change: path-editing layer bounds, #1220; issue, PR and catalog references gap 21: percentage guides and effect colour labels in; earlier change: re-audited against `upstream/main` 470ea7a3; ranked gap list from the 10 October re-measure otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89)
|
||||
|
||||
An honest assessment of how far EffectCraft is from being a real replacement for After Effects,
|
||||
and the work that closes the gap. This document is meant for contributors and agents choosing
|
||||
@@ -81,7 +81,7 @@ are grouped by area; the issue tracker has the rest.
|
||||
| 22 | **Encoder efficiency** | HEVC / AV1 have no B-frames, multi-reference or SAO / CDEF; Opus no FEC / DTX; single-file intermediates are 8-bit uncompressed QuickTime only (#372 closed by #778: no 16-bpc, AVI or lossless-compressed codec); #325 render over existing file | Larger files than After Effects + Media Encoder | 20–40 h | G6 · [file-format-parity.md](file-format-parity.md) |
|
||||
| 23 | **Essential Graphics interchange** | Templates are `.ectemplate`, not `.mogrt`; #338 and #611 closed | Premiere editors can't use our templates | 15–30 h | — · [file-format-parity.md](file-format-parity.md) |
|
||||
| 24 | **Timeline depth requests** | #601 expand precomps in place, #602 reorderable render stack, #604 guide-only effects, #425 keyframe confusion (#355 and #428 closed) | Power-user speed | 15–30 h | G2 · [ui-parity.md](ui-parity.md) |
|
||||
| 25 | **Accessibility** | AccessKit is enabled in eframe and tests read focus labels; the Project panel has After Effects' arrow-key navigation and named, stateful AccessKit nodes (#776), but the other panels have had no screen-reader pass or keyboard navigation audit, and there is no contrast check | Excludes users; After Effects supports screen readers poorly too | 15–30 h | G9 · [ui-parity.md](ui-parity.md) |
|
||||
| 25 | **Accessibility** | AccessKit is enabled in eframe. `ui_accessibility_audit` walks the AccessKit tree and counts unnamed or roleless controls per panel; Project (#776), Effects & Presets, Settings (every page) and the Render Queue with its Templates dialog are at zero and held there, with named, stateful nodes and arrow-key, Home / End, Enter and Space operation of their lists (#1335). The other 30 or so panels are counted but not fixed (the Timeline alone has about 55 unnamed controls; the baseline in the test shows each). `ui_contrast` checks the theme tokens against WCAG 2: 13 of 48 pairs fall short (primary-button text, error text, control borders, the whole Light theme's warning colour). **No screen reader has been run on any panel**: the tree is checked, not how NVDA, VoiceOver or Orca speak it. Not covered: keyboard-only operation of the other panels, focus order across panels, colour-blindness, text size, reduced motion. App shortcuts pause while any widget has keyboard focus (egui), so Ctrl+Z does nothing with a list row focused | Excludes users; After Effects supports screen readers poorly too | 12–25 h (first slice, about 3 h, done) | G9 · [ui-parity.md](ui-parity.md) |
|
||||
|
||||
## Where we're going: workstreams in priority order
|
||||
|
||||
@@ -235,7 +235,7 @@ The most important missing piece: it turns every other estimate here into a meas
|
||||
|
||||
Kept from the 5 October assessment's "Real-user experience" row, as evidence of what users hit and what was fixed:
|
||||
|
||||
Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the viewer snaps back, panels can't be resized or rearranged, drag-and-drop and double-click import don't work, layer rename gets stuck, the Layer Settings arrow does nothing, the Project panel clips, the Wayland window icon is generic. Issues #63–#68 (macOS, 5 October): scaling a layer by its handles goes wrong and can stick at 0, two 3D compasses, the viewer lags while a layer is dragged (a frame renders in 20 ms but showed after ≈ 100 ms, behind RAM preview prefetch), the Discord and other links do nothing, Delete doesn't delete in the Project panel, hidden layers can be selected in the viewer. All but the last two were fixed or confirmed fixed with a test on 5–6 October (those two have community PRs); file drops still can't work on Wayland (winit has no support). The panning, rename and arrow bugs had been fixed in v0.2.0 already; nobody had told the reporter. A Windows user (6 October, on Discord) found the font menus listed only the three bundled fonts: installed fonts were read only when a project asked for one, on every platform. Fixed with a regression test on 6 October; the menus now list every installed family and its own styles, and agents get `text.fonts` / `list_fonts`. Seven reports of 6 October (Linux .deb and Windows): Project items and files couldn't be dropped on the viewer (#85), nor effects on a layer there (#88); drops in the Timeline ignored where they landed (#89); an angle's revolutions couldn't be edited (#93); toggling Audio, Lock or Shy threw away the RAM preview and playback with audio skipped frames (#103); running out of video memory panicked every frame thread and left frames stuck (#106); choosing a Render Settings template in the Render Queue did nothing (#117: any popup menu moved up to fit the window closed on the press). All seven were fixed with regression tests on 7 October. Reports of 7 October (Linux, macOS, Windows): the app opened the demo project on every launch, so a new comp landed in it (#204); a comp wider than the GPU's texture limit (11000 × 2200) at Full resolution stopped the window drawing (#201); an Intel HD Graphics 5500 couldn't start the app (#198: most likely the window's device asked for limits that GPU doesn't have, and the failure was silent); Audio Spectrum / Waveform stood still in the viewer (#209) and preview sound waited for the whole work area to cache (#208); a layer couldn't be deselected from the time graph, a shape layer's Fill and Stroke showed only with a shape tool and a drawn shape had no stroke to change, Alt+Shift+P didn't reveal Position (#205); a mask selected in the Timeline wasn't selected in the viewer, Mask Feather had no link and took negative values, the Project panel had no selection box (#203); shape layers had no Add menu for Trim Paths and the other operations, and Classic 3D comps hid Geometry Options (#206); dragging Puppet pins re-rendered the layer under the Puppet at every move (#212). All were fixed with regression tests the same day (#219–#222); the #198 fix couldn't be checked on that GPU. Reports of 8 October (browser, Windows, macOS): an empty project at 1280 × 800 with device pixel ratio 2 crashed on its first frame, because the Project panel's scroll bar clamped its thumb to a track shorter than the thumb (#231, any short panel on every platform); Media Browser ▸ Add Files… dropped a file it couldn't read without a word (#226); after the page's WebGPU device was lost, the browser editor froze on its last frame while edits went on (#225); a press in the viewer always took the topmost layer, so a selected layer behind others couldn't be dragged (#230); the language didn't follow the system's (#229); and a list of missing After Effects behaviour (#227): drawing into, copying, pasting and duplicating shape contents, Tool Creates Shape / Mask and Fill / Stroke Options, Turbulent Displace, Wave Warp and Bulge pinning all but a shape layer's bottom-right quadrant, a Spacebar hold that previewed instead of being the Hand tool, the Effect menu in Effect Controls, dropping items on New Composition and dragging over layer switches. All were fixed with regression tests the same day (#235, #237–#241); multi-monitor support and the Project panel's empty-area menu (community PR #210) remain from #227, and effect points on shape and text layers still sit half a comp off. A list of tweaks from an Intel iMac (#290): the layer bar didn't show where extended footage runs past its source, and Enable Time Remapping then ended at the extended out point; footage items had no Interpret Footage in their menu and the work area bar no menu; Label menus named the labels without their colours; the arrow keys didn't nudge layers; a straight motion path had no Bezier handles; Delete on Time Remap deleted the layer; a mask being drawn with the Pen blacked out its layer until it was closed; a double-click on a mask point didn't select the whole mask. AlLine truncated
|
||||
Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the viewer snaps back, panels can't be resized or rearranged, drag-and-drop and double-click import don't work, layer rename gets stuck, the Layer Settings arrow does nothing, the Project panel clips, the Wayland window icon is generic. Issues #63–#68 (macOS, 5 October): scaling a layer by its handles goes wrong and can stick at 0, two 3D compasses, the viewer lags while a layer is dragged (a frame renders in 20 ms but showed after ≈ 100 ms, behind RAM preview prefetch), the Discord and other links do nothing, Delete doesn't delete in the Project panel, hidden layers can be selected in the viewer. All but the last two were fixed or confirmed fixed with a test on 5–6 October (those two have community PRs); file drops still can't work on Wayland (winit has no support). The panning, rename and arrow bugs had been fixed in v0.2.0 already; nobody had told the reporter. A Windows user (6 October, on Discord) found the font menus listed only the three bundled fonts: installed fonts were read only when a project asked for one, on every platform. Fixed with a regression test on 6 October; the menus now list every installed family and its own styles, and agents get `text.fonts` / `list_fonts`. Seven reports of 6 October (Linux .deb and Windows): Project items and files couldn't be dropped on the viewer (#85), nor effects on a layer there (#88); drops in the Timeline ignored where they landed (#89); an angle's revolutions couldn't be edited (#93); toggling Audio, Lock or Shy threw away the RAM preview and playback with audio skipped frames (#103); running out of video memory panicked every frame thread and left frames stuck (#106); choosing a Render Settings template in the Render Queue did nothing (#117: any popup menu moved up to fit the window closed on the press). All seven were fixed with regression tests on 7 October. Reports of 7 October (Linux, macOS, Windows): the app opened the demo project on every launch, so a new comp landed in it (#204); a comp wider than the GPU's texture limit (11000 × 2200) at Full resolution stopped the window drawing (#201); an Intel HD Graphics 5500 couldn't start the app (#198: most likely the window's device asked for limits that GPU doesn't have, and the failure was silent); Audio Spectrum / Waveform stood still in the viewer (#209) and preview sound waited for the whole work area to cache (#208); a layer couldn't be deselected from the time graph, a shape layer's Fill and Stroke showed only with a shape tool and a drawn shape had no stroke to change, Alt+Shift+P didn't reveal Position (#205); a mask selected in the Timeline wasn't selected in the viewer, Mask Feather had no link and took negative values, the Project panel had no selection box (#203); shape layers had no Add menu for Trim Paths and the other operations, and Classic 3D comps hid Geometry Options (#206); dragging Puppet pins re-rendered the layer under the Puppet at every move (#212). All were fixed with regression tests the same day (#219–#222); the #198 fix couldn't be checked on that GPU. Reports of 8 October (browser, Windows, macOS): an empty project at 1280 × 800 with device pixel ratio 2 crashed on its first frame, because the Project panel's scroll bar clamped its thumb to a track shorter than the thumb (#231, any short panel on every platform); Media Browser ▸ Add Files… dropped a file it couldn't read without a word (#226); after the page's WebGPU device was lost, the browser editor froze on its last frame while edits went on (#225); a press in the viewer always took the topmost layer, so a selected layer behind others couldn't be dragged (#230); the language didn't follow the system's (#229); and a list of missing After Effects behaviour (#227): drawing into, copying, pasting and duplicating shape contents, Tool Creates Shape / Mask and Fill / Stroke Options, Turbulent Displace, Wave Warp and Bulge pinning all but a shape layer's bottom-right quadrant, a Spacebar hold that previewed instead of being the Hand tool, the Effect menu in Effect Controls, dropping items on New Composition and dragging over layer switches. All were fixed with regression tests the same day (#235, #237–#241); multi-monitor support and the Project panel's empty-area menu (community PR #210) remain from #227, and effect points on shape and text layers still sit half a comp off. A list of tweaks from an Intel iMac (#290): the layer bar didn't show where extended footage runs past its source, and Enable Time Remapping then ended at the extended out point; footage items had no Interpret Footage in their menu and the work area bar no menu; Label menus named the labels without their colours; the arrow keys didn't nudge layers; a straight motion path had no Bezier handles; Delete on Time Remap deleted the layer; a mask being drawn with the Pen blacked out its layer until it was closed; a double-click on a mask point didn't select the whole mask. AlLine truncated
|
||||
|
||||
### G3. Stability and a green main
|
||||
|
||||
@@ -350,6 +350,7 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-16 | minor | Gap 8: #932 closed (Convert To Bezier Path converts a shape layer's parametric path, not only mask RotoBezier) |
|
||||
| 2026-10-13 | minor | Gap 25: an AccessKit audit test (per-panel counts, a ratchet for the unfixed panels) and a theme contrast test; Effects & Presets, Settings and Render Queue named and keyboard-operable (#1335); no screen reader tested |
|
||||
| 2026-10-13 | patch | G2: layer bounds no longer compete with editable shape/mask path points (#1220) |
|
||||
| 2026-10-12 | minor | Audited against `upstream/main` 470ea7a3: open-issue count and crash list, closed issues removed from rows 1, 3, 5, 6, 8–12, 15–17, 22–24, G8 and G9 (#759 `#include`), localization catalogs recounted (≈ 12% to ≈ 18%); rows 7, 14 and 21 left to open PRs #1102, #792 and #1098 |
|
||||
| 2026-10-10 | minor | Alpha gate checked: no alpha blockers |
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
# UI parity
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-18 · **Change:** minor (shape group Contents row elided; path-editing layer bounds #1220; closed issue references re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-18 · **Change:** minor (accessibility row: an AccessKit audit and a contrast test, three more panels named and keyboard-operable, #1335; earlier changes: shape group Contents row elided; path-editing layer bounds #1220; closed issue references re-audited against `upstream/main` 470ea7a3) · **Target:** Adobe After Effects 2026
|
||||
|
||||
How closely EffectCraft's interaction matches After Effects: tools, handles, snapping, nudging,
|
||||
modifier keys, shortcuts, drag behaviour, numeric entry, panels, context menus and feel. Evidence
|
||||
@@ -30,7 +30,7 @@ feel and platform differences, and the 26.x interface changes are not in yet.
|
||||
| Preview feel | Spacebar / Numpad 0 previews, RAM preview, cache bars, adaptive resolution | 55% | `ui_ram_preview`, `ui_live_preview`; stutter (#595, #304); scrubbing lag (#329, closed by #784) |
|
||||
| Home screen, Learn, templates | Home with recent projects, template gallery, interactive Learn tutorials | 70% | Layout nitpicks (#581); Home literals untranslated (#574) |
|
||||
| Quick Apply (26.2) | Search to apply effects, presets or commands | partial | A command palette exists; not After Effects' Quick Apply |
|
||||
| Accessibility | AccessKit enabled; focus labels tested; Project panel keyboard navigation and accessible names (#776) | 20% | No screen-reader pass or keyboard-navigation audit of the other panels; no contrast check |
|
||||
| Accessibility | AccessKit enabled; `ui_accessibility_audit` counts unnamed or roleless controls per panel: Project (#776), Effects & Presets, Settings (all pages) and Render Queue with Templates are at zero, with names, roles, selected / open / checked state and arrow-key, Home / End, Enter, Space operation (#1335); `ui_contrast` checks theme tokens against WCAG 2 | 20% | No screen reader has been run on anything; the other panels are counted, not fixed (Timeline ≈ 55 unnamed controls); 13 of 48 token pairs are below WCAG 2 (primary button text, error text, control borders, Light theme warning colour); focus order across panels, colour-blindness and reduced motion are unchecked; app shortcuts pause while a widget has keyboard focus |
|
||||
| UI scale | Settings ▸ Appearance ▸ UI Scale 75–200% | beyond | After Effects follows the system's scaling (#284) |
|
||||
|
||||
Path editing (#1220): the Composition viewer suppresses a selected layer’s transform box while
|
||||
@@ -42,7 +42,7 @@ cover shape paths and masks, corner-vertex dragging and undo, and layers without
|
||||
## Estimate
|
||||
|
||||
**≈ 60–110 h**: the open interaction issues above (25–45 h, about 0.2–0.5 h each with a scripted
|
||||
test), 3D gizmos 8–15 h, the 26.x interface changes 10–20 h, an accessibility pass 15–30 h. Checking feel against After Effects needs the owner's Mac with After
|
||||
test), 3D gizmos 8–15 h, the 26.x interface changes 10–20 h, an accessibility pass 12–25 h (the first slice, three panels and the audit, is done). Checking feel against After Effects needs the owner's Mac with After
|
||||
Effects running; most of the rest parallelises.
|
||||
|
||||
## Revision history
|
||||
@@ -50,6 +50,7 @@ Effects running; most of the rest parallelises.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-18 | minor | A shape group's inner Contents (After Effects' elided Vectors group) draws no row: the group's path, fill/stroke and Transform list directly under the group |
|
||||
| 2026-10-13 | minor | Accessibility row: AccessKit audit and contrast tests, Effects & Presets, Settings and Render Queue named and keyboard-operable (#1335); no screen reader tested |
|
||||
| 2026-10-13 | patch | Layer bounds are suppressed during path-point editing (#1220); shape/mask interaction regressions |
|
||||
| 2026-10-12 | minor | Closed issues (#302, #320, #491, #511, #553 and others) marked as closed instead of open; panels in their own windows (#486) |
|
||||
| 2026-10-12 | minor | Percentage guides and effect colour labels (26.5) added; rows updated |
|
||||
|
||||
Reference in new issue
Block a user