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:
@@ -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
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
#![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"))]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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