mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 21:48:20 -06:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
Generated
+3
-2
@@ -1121,13 +1121,14 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=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
File diff suppressed because one or more lines are too long
+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