Merge pull request #383 from storytold/fix/290-tweaks

Tweaks from #290: label colours, nudging, motion-path handles, Time Remap, masks, context menus
This commit is contained in:
BFlat
2026-10-09 17:28:27 +07:00
committed by GitHub
21 changed files with 791 additions and 64 deletions
+78 -20
View File
@@ -1,4 +1,5 @@
//! Align panel: `layer.align {edge, to}` and `layer.distribute {mode}`.
//! Align panel: `layer.align {edge, to}` and `layer.distribute {mode}`; the arrow keys'
//! `layer.nudge {x, y}`.
//!
//! Layers are aligned by their content bounds in comp space (source size, text glyphs, shape
//! contents through the layer's transform and its parents'). Each layer moves by a comp-space
@@ -8,11 +9,11 @@
use effectcraft_geom::{Mat4, Vec3};
use effectcraft_keyframe::Value as KValue;
use effectcraft_project::{ItemId, LayerId};
use effectcraft_project::{ItemId, Layer, LayerId};
use effectcraft_render::EvalCtx;
use serde_json::{Value, json};
use super::{CommandSpec, bad, has_layers, layer_mut, layers_p, str_p};
use super::{CommandSpec, bad, f_p, has_layers, layer_mut, layers_p, merge_p, str_p};
use crate::{EngineError, Result, Session, cmd};
/// A layer's comp-space bounds and how a comp-space offset maps into its Position.
@@ -43,27 +44,32 @@ fn placed(s: &Session, cid: ItemId, ids: &[LayerId]) -> Result<Vec<Placed>> {
x1 = x1.max(p.x);
y1 = y1.max(p.y);
}
// Parent chain: world(layer) = local(pn) ⋯ local(p1) · local(layer).
let mut par = Mat4::IDENTITY;
let mut cur = l.parent;
let mut guard = 0;
while let Some(pid) = cur {
guard += 1;
let Some(p) = comp.layer(pid).filter(|_| guard < 64) else { break };
par = ctx.local_matrix(p) * par;
cur = p.parent;
}
let to_parent = par.inverse().unwrap_or(Mat4::IDENTITY);
out.push(Placed { id, b: [x0, y0, x1, y1], to_parent });
out.push(Placed { id, b: [x0, y0, x1, y1], to_parent: to_parent(&ctx, l) });
}
Ok(out)
}
/// Move layers by comp-space offsets, as one undo step.
fn apply_moves(s: &mut Session, cid: ItemId, label: &str, moves: Vec<(LayerId, Mat4, [f64; 2])>) -> Result<Value> {
/// Comp space → the space of `l`'s Position (its parent's).
fn to_parent(ctx: &EvalCtx, l: &Layer) -> Mat4 {
// Parent chain: world(layer) = local(pn) ⋯ local(p1) · local(layer).
let mut par = Mat4::IDENTITY;
let mut cur = l.parent;
let mut guard = 0;
while let Some(pid) = cur {
guard += 1;
let Some(p) = ctx.comp.layer(pid).filter(|_| guard < 64) else { break };
par = ctx.local_matrix(p) * par;
cur = p.parent;
}
par.inverse().unwrap_or(Mat4::IDENTITY)
}
/// Move layers by comp-space offsets, as one undo step (merged into the last one with the same
/// `merge` key).
fn apply_moves(s: &mut Session, cid: ItemId, label: &str, moves: Vec<(LayerId, Mat4, [f64; 2])>, merge: Option<&str>) -> Result<Value> {
let t = s.time();
let moved: Vec<u64> = moves.iter().filter(|m| m.2[0].abs() > 1e-9 || m.2[1].abs() > 1e-9).map(|m| m.0.0).collect();
s.edit(label, None, |proj, _| {
s.edit(label, merge, |proj, _| {
for (lid, to_parent, d) in &moves {
if d[0].abs() <= 1e-9 && d[1].abs() <= 1e-9 {
continue;
@@ -144,7 +150,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
(it.id, it.to_parent, d)
})
.collect();
apply_moves(s, cid, "Align Layers", moves)
apply_moves(s, cid, "Align Layers", moves, None)
}
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
@@ -170,11 +176,29 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
(it.id, it.to_parent, if horiz { [dv, 0.0] } else { [0.0, dv] })
})
.collect();
apply_moves(s, cid, "Distribute Layers", moves)
apply_moves(s, cid, "Distribute Layers", moves, None)
}
/// The arrow keys in the Composition panel: move the selected (unlocked) layers by `x`, `y` comp
/// pixels; After Effects nudges 1 pixel at the panel's magnification, 10 with Shift. Each call
/// is one undo step.
fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
let c = "layer.nudge";
let d = [f_p(p, "x").unwrap_or(0.0), f_p(p, "y").unwrap_or(0.0)];
if !d.iter().all(|v| v.is_finite()) {
return Err(bad(c, "`x` and `y` must be numbers"));
}
let (cid, ids) = layers_p(s, p)?;
let ids = super::unlocked(s, cid, ids, c)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let ctx = EvalCtx { footage: Some(s.footage.as_ref()), expr: s.expr.as_deref(), ..EvalCtx::new(&s.project, cid, comp, s.time()) };
let moves = ids.iter().filter_map(|id| comp.layer(*id)).filter(|l| l.transform().is_some()).map(|l| (l.id, to_parent(&ctx, l), d)).collect();
apply_moves(s, cid, "Nudge", moves, merge_p(p))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("layer.nudge", "Nudge Layers", [], None, "{x?, y? (comp pixels), layers?, merge?}", has_layers, nudge),
cmd!(
"layer.align",
"Align Layers",
@@ -282,4 +306,38 @@ mod tests {
s.execute("layer.align", json!({"edge": "right", "layers": [a]})).unwrap();
assert!((bounds(&s, a)[2] - 400.0).abs() < 1e-6);
}
/// The arrow keys' nudge (#290): comp pixels through a parent's scale, sub-pixel steps, one
/// undo step each, a key at the current time when Position is animated; locked layers stay.
#[test]
fn nudge_moves_layers_by_comp_pixels() {
let mut s = session();
let pos = |s: &Session, id: u64| s.active_comp().unwrap().layer(LayerId(id)).unwrap().props.prop("transform/position").unwrap().value.as_vec3();
let a = solid(&mut s, "A", 40, 40, [100.0, 100.0]);
let parent = solid(&mut s, "P", 10, 10, [0.0, 0.0]);
s.execute("prop.set", json!({"layer": parent, "path": "transform/scale", "value": [200.0, 200.0, 100.0]})).unwrap();
let child = solid(&mut s, "C", 10, 10, [10.0, 10.0]);
s.execute("layer.setParent", json!({"layers": [child], "parent": parent})).unwrap();
let undo = s.history.undo.len();
s.execute("layer.nudge", json!({"layers": [a, child], "x": 1.0})).unwrap();
s.execute("layer.nudge", json!({"layers": [a, child], "y": -0.25})).unwrap();
assert_eq!(pos(&s, a), [101.0, 99.75, 0.0]);
assert_eq!(pos(&s, child), [10.5, 9.875, 0.0], "half as far under a 200 % parent");
assert_eq!(s.history.undo.len(), undo + 2, "one undo step per nudge");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(pos(&s, a), [101.0, 100.0, 0.0]);
// Animated Position gets a key at the current time.
s.execute("prop.addKey", json!({"layer": a, "path": "transform/position", "time": 0.0, "value": [0, 0, 0]})).unwrap();
s.execute("time.set", json!({"time": 1.0})).unwrap();
s.execute("layer.nudge", json!({"layers": [a], "x": 10.0})).unwrap();
let keys = s.active_comp().unwrap().layer(LayerId(a)).unwrap().props.prop("transform/position").unwrap().keys.clone();
assert_eq!(
keys.iter().map(|k| (k.time, k.value.as_vec3())).collect::<Vec<_>>(),
vec![(effectcraft_time::Tick::ZERO, [0.0; 3]), (s.time(), [10.0, 0.0, 0.0])]
);
// A locked layer doesn't move.
s.execute("layer.setSwitch", json!({"layers": [child], "switch": "lock", "value": true})).unwrap();
assert!(s.execute("layer.nudge", json!({"layers": [child], "x": 1.0})).is_err());
assert_eq!(pos(&s, child), [10.5, 10.0, 0.0]);
}
}
+8
View File
@@ -142,6 +142,14 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
if s.state.text_edit.is_some() && p.get("layers").is_none() {
return s.execute("text.delete", json!({}));
}
// Time Remap selected (its name, which selects all its keys) → time remapping off, not the
// layer (After Effects).
if p.get("layers").is_none() {
let remapped = super::layer_time::selected_time_remap(s);
if !remapped.is_empty() {
return super::layer_time::disable_time_remap(s, &remapped);
}
}
// Keyframes selected → delete keys; mask vertices → delete them; else layers.
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
return s.execute("keys.delete", json!({}));
+47 -3
View File
@@ -6,12 +6,12 @@
use effectcraft_keyframe::{Keyframe, Value as KV};
use effectcraft_project::build::Ids;
use effectcraft_project::{Layer, LayerSource, Node, ParamUi, Property};
use effectcraft_project::{Layer, LayerId, LayerSource, Node, ParamUi, Property};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, f_p, has_layers, layers_p, str_p};
use crate::{EngineError, Result, Session, cmd};
use crate::{EngineError, KeyRef, Result, Session, cmd};
/// Layers whose source has its own time (precomps and footage) can be remapped.
fn remappable(l: &Layer) -> bool {
@@ -86,6 +86,10 @@ fn enable_time_remap(s: &mut Session, p: &Value) -> Result<Value> {
if !comp.layers.iter().filter(|l| ids.contains(&l.id)).any(remappable) {
return Err(bad("layer.enableTimeRemap", "time remapping needs a footage or composition layer"));
}
// Each layer's source duration (stills have none): a layer extended past it ends its remap
// at the source's end, where its last frame starts to hold (After Effects).
let src_end: Vec<(LayerId, Tick)> =
comp.layers.iter().filter(|l| ids.contains(&l.id)).filter_map(|l| Some((l.id, l.source.item().and_then(|i| s.project.item(i))?.duration()?))).collect();
let on = s.edit("Enable Time Remapping", None, |proj, _| {
let mut next = proj.next_id;
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
@@ -97,7 +101,13 @@ fn enable_time_remap(s: &mut Session, p: &Value) -> Result<Value> {
if target && !has {
let mut pr = remap_prop(&mut Ids(&mut next));
let (lin, lout) = (l.layer_time(l.in_point), l.layer_time(l.out_point));
let (a, b) = (lin.min(lout), lin.max(lout));
let (a, mut b) = (lin.min(lout), lin.max(lout));
if let Some((_, end)) = src_end.iter().find(|(id, _)| *id == l.id)
&& lin <= lout
&& *end > a
{
b = b.min(*end);
}
pr.value = KV::Scalar(source_secs(l, l.in_point));
pr.keys = vec![Keyframe::new(a, KV::Scalar(a.seconds())), Keyframe::new(b, KV::Scalar(b.seconds()))];
if lin > lout {
@@ -116,6 +126,40 @@ fn enable_time_remap(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!(on))
}
/// Layers of the active comp whose Time Remap property is selected with all of its keys (clicking
/// its name selects them all, and a key selects its property): Edit ▸ Clear turns their time
/// remapping off instead of deleting the keys or the layer.
pub(crate) fn selected_time_remap(s: &Session) -> Vec<LayerId> {
let Some(comp) = s.active_comp() else { return vec![] };
let st = &s.state;
st.selected_props
.iter()
.filter(|(lid, uid)| {
comp.layer(*lid)
.and_then(|l| l.props.get("timeRemap"))
.filter(|pr| pr.uid == *uid)
.is_some_and(|pr| pr.keys.iter().all(|k| st.selected_keys.contains(&KeyRef { layer: *lid, prop: *uid, time: k.time })))
})
.map(|(lid, _)| *lid)
.collect()
}
/// Turn time remapping off on `layers` (one undo step) and drop the removed property and its
/// keys from the selection.
pub(crate) fn disable_time_remap(s: &mut Session, layers: &[LayerId]) -> Result<Value> {
let ids: Vec<u64> = layers.iter().map(|l| l.0).collect();
let r = s.execute("layer.enableTimeRemap", json!({"layers": ids, "value": false}))?;
let comp = s.active_comp();
let gone = |lid: LayerId, uid: u64| layers.contains(&lid) && comp.and_then(|c| c.layer(lid)).is_none_or(|l| l.props.find(uid).is_none());
let (props, keys): (Vec<_>, Vec<_>) = (
s.state.selected_props.iter().copied().filter(|(l, u)| !gone(*l, *u)).collect(),
s.state.selected_keys.iter().copied().filter(|k| !gone(k.layer, k.prop)).collect(),
);
s.state.selected_props = props;
s.state.selected_keys = keys;
Ok(r)
}
/// Freeze Frame: time remapping holding the current source frame for the whole layer.
fn freeze(s: &mut Session, p: &Value, last: bool) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
+2 -1
View File
@@ -589,7 +589,8 @@ fn interpolation(s: &mut Session, p: &Value) -> Result<Value> {
let roving = b_p(p, "roving");
edit_keys(s, "Keyframe Interpolation", None, move |keys, i, _| {
if let Some(sp) = spatial.as_deref() {
let (tin, tout) = effectcraft_keyframe::spatial_tangents(keys, i);
// (Bezier keeps the current handles, with handles where the path left straight.)
let (tin, tout) = effectcraft_keyframe::spatial_handles(keys, i);
let k = &mut keys[i];
match sp {
"linear" => {
+54
View File
@@ -163,6 +163,22 @@ fn velocity_dialog_values_per_dimension_and_continuous() {
undo_redo_roundtrip(&mut s, after, |s| assert_eq!(prop(s, l, "transform/scale").keys[1].in_interp, Interp::Linear));
}
/// Spatial Bezier on a straight two-key path gives both keys handles a third of the way along it
/// (they had none), so the path can be pulled into a curve; the motion stays the same (#290).
#[test]
fn spatial_bezier_gives_a_straight_path_handles() {
let (mut s, l) = setup();
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0])), (2.0, json!([300, 0]))]);
let mid = prop(&s, l, "transform/position").value_at(effectcraft_time::Tick::from_seconds_f64(0.7)).as_vec3();
select_prop_keys(&mut s, l, "transform/position");
s.execute("keys.interpolation", json!({"spatial": "bezier"})).unwrap();
let p = prop(&s, l, "transform/position");
assert!(p.keys.iter().all(|k| !k.spatial_auto));
assert_eq!((p.keys[0].spatial_out, p.keys[1].spatial_in), ([100.0, 0.0, 0.0], [-100.0, 0.0, 0.0]));
let now = p.value_at(effectcraft_time::Tick::from_seconds_f64(0.7)).as_vec3();
assert!((now[0] - mid[0]).abs() < 0.01 && now[1].abs() < 1e-9, "{mid:?} → {now:?}");
}
#[test]
fn interpolation_dialog_temporal_spatial_and_roving() {
let (mut s, l) = setup();
@@ -551,6 +567,44 @@ fn time_remap_enable_freeze_and_disable() {
assert!(s.execute("layer.enableTimeRemap", json!({"layers": [r["layer"]]})).is_err());
}
/// Edit ▸ Clear with Time Remap selected (clicking its name selects all its keys) turns time
/// remapping off instead of deleting the layer (#290); one selected key is still just a key.
#[test]
fn deleting_time_remap_turns_time_remapping_off() {
let (mut s, l) = precomp_setup();
s.execute("layer.enableTimeRemap", json!({})).unwrap();
let uid = prop(&s, l, "timeRemap").uid;
s.execute("keys.select", json!({"keys": [{"layer": l, "prop": uid, "time": 0.0}], "selectProperties": true})).unwrap();
s.execute("edit.clear", json!({})).unwrap();
assert_eq!(prop(&s, l, "timeRemap").keys.len(), 1, "only the selected key went");
s.execute("prop.select", json!({"layer": l, "prop": uid})).unwrap();
s.execute("edit.clear", json!({})).unwrap();
let comp = s.active_comp().unwrap();
assert_eq!(comp.layers.len(), 1, "the layer stays");
assert!(comp.layers[0].props.get("timeRemap").is_none(), "time remapping is off");
assert!(s.state.selected_keys.is_empty() && !s.state.selected_props.iter().any(|(_, u)| *u == uid));
assert!((square_x(&s, 1.0) - 30.0).abs() < 1.5);
// One undo step brings it back.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(prop(&s, l, "timeRemap").keys.len(), 1);
}
/// Enable Time Remapping on a layer extended past its source's end (its last frame frozen) puts
/// the end key at the source's end, not at the extended out point (#290).
#[test]
fn time_remap_on_an_extended_layer_ends_at_the_source_end() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Inner", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap();
s.execute("comp.new", json!({"name": "Outer", "width": 100, "height": 100, "frameRate": 30, "duration": 4})).unwrap();
let l = s.execute("layer.addItem", json!({"item": "Inner"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.timing", json!({"layers": [l], "out": 4.0})).unwrap();
s.execute("layer.enableTimeRemap", json!({"layers": [l]})).unwrap();
let p = prop(&s, l, "timeRemap");
let keys: Vec<(f64, KV)> = p.keys.iter().map(|k| (k.time.seconds(), k.value.clone())).collect();
assert_eq!(keys, vec![(0.0, KV::Scalar(0.0)), (2.0, KV::Scalar(2.0))]);
assert_eq!(s.active_comp().unwrap().layers[0].out_point.seconds(), 4.0, "the layer stays extended");
}
#[test]
fn freeze_on_last_frame_extends_the_layer() {
let (mut s, l) = precomp_setup();
+31
View File
@@ -299,6 +299,37 @@ pub fn spatial_tangents(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) {
if key.spatial_auto { auto_spatial(keys, i) } else { (key.spatial_in, key.spatial_out) }
}
/// Spatial tangents of key `i` as motion-path handles: [`spatial_tangents`], with a side that
/// has none but a neighbour (the ends of an auto-Bezier path, a straight two-key path) pointing
/// where the path leaves the key, toward the segment's next control point, a third of the way to
/// the neighbouring key. After Effects shows Bezier handles there to drag the path into a curve.
pub fn spatial_handles(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) {
let (mut tin, mut tout) = spatial_tangents(keys, i);
let Some(p) = keys.get(i).map(|k| v3(&k.value)) else { return (tin, tout) };
// Toward key `j`'s control point on this segment (its in side when `j` follows `i`).
let toward = |j: usize, after: bool| -> Option<[f64; 3]> {
let q = v3(&keys.get(j)?.value);
let (jin, jout) = spatial_tangents(keys, j);
let t = if after { jin } else { jout };
let d = [q[0] + t[0] - p[0], q[1] + t[1] - p[1], q[2] + t[2] - p[2]];
let dl = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
let len = ((q[0] - p[0]).powi(2) + (q[1] - p[1]).powi(2) + (q[2] - p[2]).powi(2)).sqrt() / 3.0;
(dl > 1e-9).then(|| d.map(|v| v * len / dl))
};
let none = |t: [f64; 3]| t == [0.0; 3];
if none(tout)
&& let Some(v) = toward(i + 1, true)
{
tout = v;
}
if none(tin)
&& let Some(v) = i.checked_sub(1).and_then(|j| toward(j, false))
{
tin = v;
}
(tin, tout)
}
/// Effective temporal ease of key `i` for dimension `d` (auto-Bezier keys get the neighbour slope).
fn effective_ease(keys: &[Keyframe], i: usize, d: usize, spatial: bool, out: bool) -> Ease {
let k = &keys[i];
+4 -1
View File
@@ -134,7 +134,10 @@ pub fn shapes(ctx: &EvalCtx, layer: &Layer) -> Vec<effectcraft_effects::MaskShap
/// Returns whether any mask was applied.
pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf, blur: MaskBlur) -> bool {
let Some(masks) = layer.masks() else { return false };
let list: Vec<_> = masks.groups().filter(|g| g.enabled && matches!(g.kind, GroupKind::Mask { mode, .. } if mode != MaskMode::None)).collect();
// An open mask (a path the Pen is still drawing) makes no transparency: the layer shows
// whole until the path is closed (After Effects; effects still use it as a path).
let closed = |g: &effectcraft_project::PropGroup| matches!(ctx.group_value(layer, g, "path"), Some(Value::Path(sp)) if sp.closed);
let list: Vec<_> = masks.groups().filter(|g| g.enabled && matches!(g.kind, GroupKind::Mask { mode, .. } if mode != MaskMode::None) && closed(g)).collect();
if list.is_empty() {
return false;
}
+20
View File
@@ -368,6 +368,26 @@ fn effects_see_layer_masks() {
assert!(img.get(150, 50)[3] > 0.99, "mode None mask does not cut the layer");
}
/// An open mask (the Pen still drawing it) leaves the layer whole; closed, it masks (#290).
#[test]
fn open_masks_make_no_transparency() {
let render = |closed: bool| {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100);
let path = ShapePath { closed, ..ShapePath::rect([50.0, 50.0], 40.0, 40.0) };
let mut next = p.next_id;
let m = build::mask(&mut Ids(&mut next), "Mask 1", path, MaskMode::Add, [255, 255, 0]);
p.next_id = next;
l.props.sub_mut("masks").unwrap().children.push(m.into());
p.comp_mut(cid).unwrap().layers.push(l);
render_frame(&p, cid, Tick::ZERO, 1.0)
};
let open = render(false);
assert!(open.get(50, 50)[3] > 0.99 && open.get(150, 50)[3] > 0.99, "the open mask cut the layer");
let closed = render(true);
assert!(closed.get(50, 50)[3] > 0.99 && closed.get(150, 50)[3] < 0.01, "the closed mask masks");
}
#[test]
fn effects_read_layer_params() {
let (mut p, cid, comp) = setup();
+86 -4
View File
@@ -35,6 +35,14 @@ pub const UI_COMMANDS: &[UiCommand] = &[
uic!("view.fit", "Fit", [], Some("Shift+/")),
uic!("view.actualSize", "100%", [], Some("/")),
uic!("view.res.auto", "Resolution: Auto", [], None),
uic!("viewer.nudge.up", "Move Selected Layers Up 1 Pixel at Current Magnification", [], Some("ArrowUp")),
uic!("viewer.nudge.down", "Move Selected Layers Down 1 Pixel at Current Magnification", [], Some("ArrowDown")),
uic!("viewer.nudge.left", "Move Selected Layers Left 1 Pixel at Current Magnification", [], Some("ArrowLeft")),
uic!("viewer.nudge.right", "Move Selected Layers Right 1 Pixel at Current Magnification", [], Some("ArrowRight")),
uic!("viewer.nudge.up10", "Move Selected Layers Up 10 Pixels at Current Magnification", [], Some("Shift+ArrowUp")),
uic!("viewer.nudge.down10", "Move Selected Layers Down 10 Pixels at Current Magnification", [], Some("Shift+ArrowDown")),
uic!("viewer.nudge.left10", "Move Selected Layers Left 10 Pixels at Current Magnification", [], Some("Shift+ArrowLeft")),
uic!("viewer.nudge.right10", "Move Selected Layers Right 10 Pixels at Current Magnification", [], Some("Shift+ArrowRight")),
uic!("view.safeMargins", "Title/Action Safe", [], None),
uic!("view.transparencyGrid", "Transparency Grid", [], None),
uic!("view.fastPreviews", "Fast Previews", [], None),
@@ -229,6 +237,33 @@ fn reveal_targets(app: &EffectcraftApp) -> Vec<u64> {
}
}
/// The arrow keys over the Composition panel or the Timeline: move 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. Not while typing,
/// or with mask points selected.
fn nudge(app: &mut EffectcraftApp, ctx: &egui::Context, dir: &str) -> Result<Value, String> {
let (side, n) = match dir.strip_suffix("10") {
Some(side) => (side, 10.0),
None => (dir, 1.0),
};
let (x, y) = match side {
"up" => (0.0, -n),
"down" => (0.0, n),
"left" => (-n, 0.0),
"right" => (n, 0.0),
_ => return Err(format!("unknown nudge `{dir}`")),
};
let st = &app.session.state;
let over = matches!(app.ui.focused, PanelKind::Composition | PanelKind::Viewer(_) | PanelKind::Timeline);
if !over || st.selected_layers.is_empty() || st.text_edit.is_some() || !st.selected_vertices.is_empty() {
return Ok(Value::Null);
}
// Screen pixels per comp pixel.
let zoom = (app.ui.viewer.zoom.unwrap_or_else(|| crate::panels::viewer::last_fit(ctx)) * ctx.pixels_per_point()) as f64;
let k = if zoom.is_finite() && zoom > 1e-6 { 1.0 / zoom } else { 1.0 };
run_engine(app, ctx, "layer.nudge", json!({"x": x * k, "y": y * k}))
}
fn no_params(p: &Value) -> bool {
p.as_object().is_none_or(|m| m.is_empty())
}
@@ -336,6 +371,9 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
app.ui.tool = tool;
return Ok(json!({"tool": tool}));
}
if let Some(dir) = id.strip_prefix("viewer.nudge.") {
return nudge(app, ctx, dir);
}
if let Some(k) = id.strip_prefix("timeline.reveal.") {
if k == "animated" {
return run_engine(app, ctx, "anim.reveal", json!({"kind": "keyframes"}));
@@ -1456,7 +1494,11 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
.iter()
.filter_map(|e| match e {
egui::Event::Key { key, pressed: true, modifiers, repeat, .. }
if !*repeat || matches!(key, egui::Key::PageUp | egui::Key::PageDown | egui::Key::ArrowLeft | egui::Key::ArrowRight) =>
if !*repeat
|| matches!(
key,
egui::Key::PageUp | egui::Key::PageDown | egui::Key::ArrowLeft | egui::Key::ArrowRight | egui::Key::ArrowUp | egui::Key::ArrowDown
) =>
{
Some((*key, *modifiers, true))
}
@@ -1626,6 +1668,38 @@ fn menu_nodes(app: &mut EffectcraftApp, ui: &mut egui::Ui, nodes: &[MenuNode], c
}
}
/// The menu bar's submenu at `path` (`["File", "Interpret Footage"]`) in a panel's context menu,
/// with the menu bar's labels, shortcuts and enabled state; a chosen entry goes to `clicked`.
pub(crate) fn submenu_at(app: &mut EffectcraftApp, ui: &mut egui::Ui, path: &[&str], clicked: &mut Option<(String, Value)>) {
let mut nodes = effectcraft_engine::menus::menu_bar();
let mut found = None;
for label in path {
let Some(n) = nodes.iter().find(|n| matches!(n, MenuNode::Submenu { label: l, .. } if l == label)) else { return };
if let MenuNode::Submenu { children, .. } = n {
nodes = children;
}
found = Some(n);
}
if let Some(n) = found {
menu_nodes(app, ui, std::slice::from_ref(n), clicked);
}
if let Some((_, p)) = clicked.as_mut()
&& p.is_null()
{
*p = json!({});
}
}
/// The menu bar's entry for `command` in a panel's context menu (see [`submenu_at`]).
pub(crate) fn entry_for(app: &EffectcraftApp, ui: &mut egui::Ui, command: &str, clicked: &mut Option<(String, Value)>) {
if let Some((_, e)) = effectcraft_engine::menus::entries().into_iter().find(|(_, e)| e.command == command)
&& menu_entry(app, ui, e)
{
*clicked = Some((e.command.clone(), e.params_or_empty()));
ui.close();
}
}
/// Frontend state for dynamic menus (the current workspace and the saved ones).
pub(crate) fn dyn_ctx<'a>(workspace: &'a str, saved_workspaces: &'a [String]) -> effectcraft_engine::menus::DynCtx<'a> {
effectcraft_engine::menus::DynCtx { workspace: Some(workspace), saved_workspaces }
@@ -1638,12 +1712,20 @@ fn gutter(checked: bool) -> egui::Atom<'static> {
}
fn menu_entry(app: &EffectcraftApp, ui: &mut egui::Ui, e: &MenuEntry) -> bool {
let label = entry_label(app, e);
let mut b = egui::Button::new((gutter(entry_checked(app, e) == Some(true)), label));
let (label, check, enabled) = (entry_label(app, e), gutter(entry_checked(app, e) == Some(true)), entry_enabled(app, e));
// Edit ▸ Label's entries show the label's colour before its name.
if e.command == "edit.label"
&& let Some(l) = e.params.get("label").and_then(Value::as_str).and_then(effectcraft_engine::color::Label::from_name)
{
let out = ui.add_enabled_ui(enabled, |ui| egui::Button::new((check, crate::widgets::label_swatch(), label)).atom_ui(ui)).inner;
crate::widgets::paint_label_swatch(ui, &out, l, &app.tokens);
return out.response.clicked();
}
let mut b = egui::Button::new((check, label));
if let Some(s) = entry_shortcut(app, e) {
b = b.shortcut_text(shortcut_text(&s));
}
ui.add_enabled(entry_enabled(app, e), b).clicked()
ui.add_enabled(enabled, b).clicked()
}
#[cfg(test)]
+4 -6
View File
@@ -190,12 +190,10 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let labels = effectcraft_engine::color::Label::ALL;
let cur = labels.get(d.label).copied().unwrap_or_default();
let r = egui::ComboBox::from_id_salt("dialog.marker.label").selected_text(app.session.prefs.label_name(cur)).show_ui(ui, |ui| {
for (i, l) in labels.iter().enumerate() {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover());
ui.painter().rect_filled(r, 2.0, app.tokens.label(*l));
ui.selectable_value(&mut d.label, i, app.session.prefs.label_name(*l));
});
for (i, l) in labels.into_iter().enumerate() {
if crate::widgets::label_entry(ui, l, &app.session.prefs.label_name(l), d.label == i, &app.tokens).clicked() {
d.label = i;
}
}
});
app.auto.add("dialog.marker.label", r.response.rect, "Label");
+42 -8
View File
@@ -8,6 +8,7 @@
use std::sync::{Arc, Mutex};
use effectcraft_engine::color::Label;
use effectcraft_engine::project::{Item, ItemId, ItemKind};
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
use serde_json::json;
@@ -485,14 +486,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let sresp = ui.interact(sw.expand(3.0).intersect(list), egui::Id::new(("plabel", id.0)), Sense::click());
app.auto.add(&format!("project.item.{}.label", id.0), sw, it.label.name());
let pop = egui::Id::new(("plabel-pop", id.0));
if sresp.clicked() {
if sresp.clicked() || sresp.secondary_clicked() {
widgets::open_popup(ui, pop);
}
let names: Vec<String> = effectcraft_engine::color::Label::ALL.iter().map(|l| app.session.prefs.label_name(*l)).collect();
let cur = effectcraft_engine::color::Label::ALL.iter().position(|l| *l == it.label);
if let Some(li) = widgets::popup_menu(ui, pop, sw.left_bottom(), &names, cur) {
let items: Vec<u64> = if selected { app.session.state.project_selection.iter().map(|i| i.0).collect() } else { vec![id.0] };
actions.push(("project.setLabel".into(), json!({"items": items, "label": li})));
if let Some(l) = widgets::label_popup(ui, pop, sw.left_bottom(), it.label, |l| app.session.prefs.label_name(l), &t) {
actions.push(("project.setLabel".into(), json!({"items": targets(app, *id, selected), "label": label_index(l)})));
}
// Optional columns.
for (key, _, cx, w) in cols.iter().skip(2) {
@@ -574,10 +572,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if resp.drag_started() {
egui::DragAndDrop::set_payload(&ctx, DragPayload::Item(id.0));
}
// Right-clicking an item selects it (After Effects): the menu's File commands act on the
// selection.
if resp.secondary_clicked() && !selected {
app.session.state.project_selection = vec![*id];
}
let footage = matches!(it.kind, ItemKind::Footage(_));
resp.context_menu(|ui| {
if ui.button("Delete").clicked() {
let items: Vec<u64> = if selected { app.session.state.project_selection.iter().map(|i| i.0).collect() } else { vec![id.0] };
actions.push(("project.delete".into(), json!({"items": items})));
actions.push(("project.delete".into(), json!({"items": targets(app, *id, selected)})));
ui.close();
}
if matches!(it.kind, ItemKind::Comp(_)) && ui.button("Open Composition").clicked() {
@@ -604,6 +607,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
ui.close();
}
ui.separator();
ui.menu_button("Label", |ui| {
for l in Label::ALL {
if widgets::label_entry(ui, l, &app.session.prefs.label_name(l), l == it.label, &t).clicked() {
actions.push(("project.setLabel".into(), json!({"items": targets(app, *id, selected), "label": label_index(l)})));
ui.close();
}
}
});
// Footage: the File menu's footage commands (Interpret Footage, Replace Footage…).
if footage {
let mut chosen = None;
crate::menus::submenu_at(app, ui, &["File", "Interpret Footage"], &mut chosen);
crate::menus::submenu_at(app, ui, &["File", "Replace Footage"], &mut chosen);
crate::menus::entry_for(app, ui, "file.reloadFootage", &mut chosen);
crate::menus::entry_for(app, ui, "file.revealInFinder", &mut chosen);
actions.extend(chosen);
}
});
}
// The scroll bars, over the rows (which would take their presses otherwise).
@@ -674,9 +695,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let resp = widgets::icon_button(ui, r, icon, false, &t, egui::Id::new(("pfoot", id))).on_hover_text(tip);
app.auto.add(&format!("project.{id}"), r, tip);
if resp.clicked() {
let footage =
app.session.state.project_selection.first().and_then(|i| app.session.project.item(*i)).is_some_and(|i| matches!(i.kind, ItemKind::Footage(_)));
match id {
"newComp" => crate::panels::dialogs::open_new_comp(app),
"newFolder" => actions.push(("project.newFolder".into(), json!({}))),
_ if footage => actions.push(("file.interpretFootage".into(), json!({}))),
_ => app.ui.status = "Interpret Footage: select a footage item".into(),
}
}
@@ -775,6 +799,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
/// Items a row's menu acts on: the selection when the row is selected, else the row's item.
fn targets(app: &EffectcraftApp, id: ItemId, selected: bool) -> Vec<u64> {
if selected { app.session.state.project_selection.iter().map(|i| i.0).collect() } else { vec![id.0] }
}
/// `project.setLabel`'s index of a label.
fn label_index(l: Label) -> usize {
Label::ALL.iter().position(|x| *x == l).unwrap_or(0)
}
/// The column header's context menu: show or hide the optional columns.
fn column_menu(app: &mut EffectcraftApp, resp: &egui::Response) {
resp.context_menu(|ui| {
+50 -12
View File
@@ -73,6 +73,23 @@ pub(crate) fn bar_color(label: Color32, selected: bool) -> Color32 {
Color32::from_rgb(mix(label.r()), mix(label.g()), mix(label.b()))
}
/// Darken part `r` of a layer bar and stripe it diagonally in its label colour (where the layer
/// runs past its source). Only the visible part is drawn.
fn stripes(p: &egui::Painter, r: Rect, label: Color32) {
let v = r.intersect(p.clip_rect());
if !v.is_positive() {
return;
}
let p = p.with_clip_rect(v);
p.rect_filled(v, 0.0, Color32::from_black_alpha(90));
let h = v.height();
let mut x = v.min.x - h;
while x < v.max.x {
p.line_segment([pos2(x, v.max.y), pos2(x + h, v.min.y)], Stroke::new(1.0, label.gamma_multiply(0.7)));
x += 6.0;
}
}
/// Row height (expression editors grow with their text).
fn row_height(row: &Row, rh: f32) -> f32 {
match row.kind {
@@ -1242,8 +1259,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
p.with_clip_rect(wa_row).rect_filled(wa, 0.0, WORK_AREA_BAR);
let shift = ui.input(|i| i.modifiers.shift);
let bar = wa.intersect(wa_row);
let bresp = ui.interact(bar, egui::Id::new("wa-bar"), Sense::drag());
let bresp = ui.interact(bar, egui::Id::new("wa-bar"), Sense::click_and_drag());
app.auto.add("timeline.workArea.bar", bar, "Work area");
// Right-click: the work area's commands (After Effects).
bresp.context_menu(|ui| {
let mut chosen = None;
for id in ["comp.trimToWorkArea", "edit.liftWorkArea", "edit.extractWorkArea"] {
crate::menus::entry_for(app, ui, id, &mut chosen);
}
actions.extend(chosen);
});
let bar_start = egui::Id::new("wa-bar-start");
if bresp.drag_started() {
ctx.data_mut(|d| d.insert_temp(bar_start, wa0));
@@ -1582,15 +1607,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
lp.rect_filled(lr, 2.0, t.label(layer.label));
let lresp = ui.interact(lr, egui::Id::new(("label", layer.id.0)), Sense::click());
app.auto.add(&format!("timeline.layer.{}.label", layer.id.0), lr, layer.label.name());
lresp.context_menu(|ui| {
for lab in effectcraft_engine::color::Label::ALL {
let name = if lab == effectcraft_engine::color::Label::None { lab.name().to_string() } else { app.session.prefs.label_name(lab) };
if ui.button(name).clicked() {
actions.push(("edit.label".into(), json!({"layers": [layer.id.0], "label": lab.name()})));
ui.close();
}
}
});
// Click (or right-click) the swatch for the label menu (After Effects).
let pop = egui::Id::new(("label-pop", layer.id.0));
if lresp.clicked() || lresp.secondary_clicked() {
widgets::open_popup(ui, pop);
}
if let Some(lab) = widgets::label_popup(ui, pop, lr.left_bottom(), layer.label, |l| app.session.prefs.label_name(l), &t) {
actions.push(("edit.label".into(), json!({"layers": [layer.id.0], "label": lab.name()})));
}
}
if vis.num {
lp.text(pos2(cw.num + 13.0, cy), Align2::CENTER_CENTER, format!("{}", idx_of(layer.id)), Tokens::ui(11.5), t.text_dim);
@@ -1818,6 +1842,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let xe = tm.x(layer.comp_time(d).seconds());
let ghost = Rect::from_min_max(pos2(xs.min(xe), r.min.y + 9.0), pos2(xs.max(xe), r.max.y - 9.0));
gp.rect_filled(ghost, 1.0, lc.gamma_multiply(0.18));
// The bar past the source's end (its last frame held) or before its start
// is striped, so the source's own span shows (#290). Time remapping
// decides what shows there itself.
if layer.props.get("timeRemap").is_none() {
for (side, part) in [
("beforeSource", Rect::from_min_max(bar.min, pos2(ghost.min.x, bar.max.y))),
("afterSource", Rect::from_min_max(pos2(ghost.max.x, bar.min.y), bar.max)),
] {
if part.width() >= 1.0 {
stripes(&gp, part, lc);
app.auto.add(&format!("timeline.layer.{}.bar.{side}", layer.id.0), part, "Past the source");
}
}
}
// Dragging the source bar outside the in/out span slips the source.
for (side, gr) in [
("l", Rect::from_min_max(ghost.min, pos2(bar.min.x, ghost.max.y))),
@@ -2353,8 +2391,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if lbl == "Select All Keyframes" {
// Keyframe colour labels.
ui.menu_button("Label", |ui| {
for (i, l) in effectcraft_engine::color::Label::ALL.iter().enumerate() {
if ui.add(egui::Button::new(l.name()).selected(k.label as usize == i)).clicked() {
for (i, l) in effectcraft_engine::color::Label::ALL.into_iter().enumerate() {
if widgets::label_entry(ui, l, &app.session.prefs.label_name(l), k.label as usize == i, &t).clicked() {
if !ks {
actions.push((
"keys.select".into(),
+7 -2
View File
@@ -1603,8 +1603,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
&& let Some(pos) = resp.interact_pointer_pos()
&& let Some((l, m)) = vertex_at(pos, false).map(|h| (h.layer, h.mask)).or_else(|| ov::segment_at(&paths, &map, pos, 6.0).map(|s| (s.0, s.1)))
{
// Double-click a path: free transform its points (Layer ▸ Mask and Shape Path ▸ Free
// Transform Points).
// Double-click a path: all its points selected in a free-transform box (Layer ▸ Mask and
// Shape Path ▸ Free Transform Points), so dragging any of them, or inside the box, moves
// the whole path (After Effects).
if let Some(p) = paths.iter().find(|p| p.layer == l && p.uid == m) {
let all: Vec<serde_json::Value> = (0..p.sp.vertices.len()).map(|index| json!({"layer": l.0, "mask": m, "index": index})).collect();
let _ = app.session.execute("mask.selectVertices", json!({"vertices": all}));
}
ov::begin_free_transform(&ctx, l, m);
} else if resp.double_clicked()
&& !text_owns
+7 -1
View File
@@ -292,7 +292,13 @@ pub(crate) fn motion_path(app: &mut EffectcraftApp, painter: &egui::Painter, map
}
let v = k.value.as_vec3();
let s = to_scr(v);
let (tin, tout) = effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i);
// Auto-Bezier keys show handles on a side the path leaves straight too (a two-key path's
// ends), to drag it into a curve; Linear keys (no tangents) show none.
let (tin, tout) = if k.spatial_auto {
effectcraft_engine::keyframe::spatial_handles(&pos.keys, i)
} else {
effectcraft_engine::keyframe::spatial_tangents(&pos.keys, i)
};
for (out, tg) in [(false, tin), (true, tout)] {
let has = (out && i + 1 < pos.keys.len()) || (!out && i > 0);
if !has || (tg[0] == 0.0 && tg[1] == 0.0) {
+55
View File
@@ -365,6 +365,61 @@ pub fn check_label(on: bool, label: &str) -> String {
if on { format!("✓ {label}") } else { format!(" {label}") }
}
fn label_swatch_id() -> egui::Id {
egui::Id::new("label-swatch")
}
/// The colour swatch of a Label menu row, as an atom for its button: Label menus show each
/// label's colour before its name (After Effects), so a label is picked by its colour. Paint it
/// with [`paint_label_swatch`] once the button is laid out.
pub fn label_swatch() -> egui::Atom<'static> {
egui::Atom::custom(label_swatch_id(), vec2(12.0, 12.0))
}
/// Paint the [`label_swatch`] of a laid-out button: label `l`'s colour, an empty box for None.
pub fn paint_label_swatch(ui: &Ui, out: &egui::AtomLayoutResponse, l: effectcraft_engine::color::Label, t: &Tokens) {
let Some(r) = out.rect(label_swatch_id()) else { return };
let r = Rect::from_center_size(r.center(), vec2(11.0, 11.0));
if l == effectcraft_engine::color::Label::None {
ui.painter().rect_stroke(r, 2.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside);
} else {
ui.painter().rect_filled(r, 2.0, t.label(l));
}
}
/// A Label menu row: label `l`'s swatch then `name`, highlighted when it is the `current` label.
pub fn label_entry(ui: &mut Ui, l: effectcraft_engine::color::Label, name: &str, current: bool, t: &Tokens) -> Response {
let out = egui::Button::selectable(current, (label_swatch(), name)).atom_ui(ui);
paint_label_swatch(ui, &out, l, t);
out.response
}
/// The open label menu `id` at `pos` (open it with [`open_popup`]): every label's swatch and its
/// name from Settings ▸ Labels (`name`). Returns the chosen label.
pub fn label_popup(
ui: &mut Ui,
id: egui::Id,
pos: egui::Pos2,
current: effectcraft_engine::color::Label,
name: impl Fn(effectcraft_engine::color::Label) -> String,
t: &Tokens,
) -> Option<effectcraft_engine::color::Label> {
if !popup_is_open(ui, id) {
return None;
}
let labels = effectcraft_engine::color::Label::ALL;
popup_list(ui, id, pos, Rect::NOTHING, labels.len(), 0, |ui| {
ui.set_min_width(160.0);
let mut chosen = None;
for l in labels {
if label_entry(ui, l, &name(l), l == current, t).clicked() {
chosen = Some(l);
}
}
chosen
})
}
/// 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) {
+23
View File
@@ -153,6 +153,29 @@ fn saved_workspace_is_listed_in_the_workspace_menu() {
assert_eq!(h.state().ui.dock, saved, "its layout came back");
}
/// Edit ▸ Label shows each label's colour before its name (#290), and choosing one labels the
/// selected layer.
#[test]
fn edit_label_menu_shows_the_label_colours() {
let mut h = harness();
click(&mut h, "menu.Edit");
let label = h.query_by_label(" Label ⏵").expect("Edit ▸ Label").rect();
hover(&mut h, label.center());
// (Not Dark Green; the entry's label starts with its check-mark column.)
let green = h.query_by_label(" Green").expect("the Label submenu is open").rect();
let col = h.state().tokens.label(effectcraft_engine::color::Label::Green);
let swatch = |s: &egui::Shape| matches!(s, egui::Shape::Rect(r) if r.fill == col && r.rect.width() < 16.0 && green.contains(r.rect.center()));
let shapes: Vec<egui::Shape> = h.output().shapes.iter().map(|c| c.shape.clone()).collect();
assert!(shapes.iter().any(|s| swatch(s) || matches!(s, egui::Shape::Vec(v) if v.iter().any(swatch))), "no Green swatch");
// Into the submenu, then down to Green.
hover(&mut h, pos2(green.center().x, label.center().y));
for k in 1..=10 {
hover(&mut h, pos2(green.center().x, label.center().y + (green.center().y - label.center().y) * k as f32 / 10.0));
}
click_at(&mut h, green.center());
assert_eq!(h.state().session.active_comp().unwrap().layers[0].label, effectcraft_engine::color::Label::Green);
}
/// Learn opens the Home screen's tutorials; clicking another workspace tab closes them again,
/// however quickly the tabs are clicked (they stayed over every workspace, #272).
#[test]
+55 -3
View File
@@ -3,9 +3,9 @@
use effectcraft_engine::Session;
use effectcraft_engine::color::Label;
use effectcraft_engine::project::{ItemId, ItemKind};
use effectcraft_ui_egui::EffectcraftApp;
use egui::{Event, Modifiers, Pos2, pos2};
use effectcraft_engine::project::{Footage, FootageKind, ItemId, ItemKind};
use effectcraft_ui_egui::{Dialog, EffectcraftApp};
use egui::{Color32, Event, Modifiers, Pos2, Rect, pos2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
@@ -80,6 +80,18 @@ fn folder_twirl_opens_and_closes_the_folder_on_one_click() {
assert!(h.state().ui.project_open_folders.contains(&folder.0), "a double-click on the row opens the folder");
}
/// A swatch (a small rect filled with `col`) painted this frame in the left half of `row`.
fn swatch_in(h: &Harness<'_, EffectcraftApp>, row: Rect, col: Color32) -> bool {
fn walk(s: &egui::Shape, row: Rect, col: Color32) -> bool {
match s {
egui::Shape::Rect(r) => r.fill == col && r.rect.width() < 16.0 && row.contains(r.rect.center()) && r.rect.center().x < row.center().x,
egui::Shape::Vec(v) => v.iter().any(|s| walk(s, row, col)),
_ => false,
}
}
h.output().shapes.iter().any(|c| walk(&c.shape, row, col))
}
#[test]
fn label_swatch_opens_the_label_menu() {
let (mut h, _, _, solid) = harness();
@@ -89,6 +101,8 @@ fn label_swatch_opens_the_label_menu() {
click(&mut h, &format!("project.item.{}.label", solid.0));
assert!(h.state().session.state.project_selection.is_empty(), "the swatch does not select the row");
let entry = h.query_by_label(&green).expect("the label menu is open").rect();
// Each entry shows its colour before its name (#290).
assert!(swatch_in(&h, entry, h.state().tokens.label(Label::Green)), "no {green} swatch");
let undo = h.state().session.history.undo.len();
click_at(&mut h, entry.center());
assert_eq!(h.state().session.project.item(solid).unwrap().label, Label::Green);
@@ -96,6 +110,44 @@ fn label_swatch_opens_the_label_menu() {
assert!(h.query_by_label(&green).is_none(), "the menu closed after the choice");
}
/// Right-clicking a footage item selects it and offers the File menu's footage commands:
/// Interpret Footage ▸ Main... opens the Interpret Footage dialog on it (#290). A solid has none.
#[test]
fn footage_context_menu_interprets_the_footage() {
let (mut h, _, _, solid) = harness();
let footage = Footage { kind: FootageKind::Video, path: "clip.mp4".into(), width: 64, height: 36, has_video: true, ..Default::default() };
let clip = std::sync::Arc::make_mut(&mut h.state_mut().session.project).add_item("Clip", Default::default(), None, ItemKind::Footage(footage));
h.run_steps(2);
let right_click = |h: &mut Harness<'_, EffectcraftApp>, p: Pos2| {
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::Secondary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(2);
};
let at = center(&h, &format!("project.item.{}.name", solid.0));
right_click(&mut h, at);
assert_eq!(h.state().session.state.project_selection, vec![solid]);
assert!(h.query_by_label_contains("Interpret Footage").is_none(), "a solid isn't footage");
h.input_mut().events.push(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
let at = center(&h, &format!("project.item.{}.name", clip.0));
right_click(&mut h, at);
assert_eq!(h.state().session.state.project_selection, vec![clip], "right-click selects the item");
let sub = h.query_by_label_contains("Interpret Footage").expect("Interpret Footage in the menu").rect();
h.input_mut().events.push(Event::PointerMoved(sub.center()));
h.run_steps(3);
let main = h.query_by_label_contains("Main...").expect("Interpret Footage ▸ Main...").rect();
for k in 0..=6 {
h.input_mut().events.push(Event::PointerMoved(pos2(main.center().x, sub.center().y + (main.center().y - sub.center().y) * k as f32 / 6.0)));
h.step();
}
click_at(&mut h, main.center());
assert_eq!(h.state().dialog, Some(Dialog::Form), "the Interpret Footage dialog opened");
}
/// Dragging from the Project panel's empty area draws a selection box that selects the rows it
/// touches; a click there deselects (#203).
#[test]
+58 -2
View File
@@ -1,12 +1,15 @@
//! Timeline layer switches with real pointer input (egui_kittest): pressing a switch and
//! dragging over other layers gives them all the state the first one got, in one undo step, as
//! in After Effects (#227).
//! in After Effects (#227); stopwatches the same way (#284). Also the label swatch's menu and the
//! layer bar past its source (#290).
use effectcraft_engine::Session;
use effectcraft_engine::color::Label;
use effectcraft_engine::project::LayerId;
use effectcraft_ui_egui::EffectcraftApp;
use egui::{Event, Modifiers, Pos2, Rect, pos2, vec2};
use egui::{Color32, Event, Modifiers, Pos2, Rect, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
/// Comp "Main" with solids A (top) to D (bottom); returns their ids top to bottom.
@@ -144,3 +147,56 @@ fn dragging_over_stopwatches_sets_them_all() {
h.run_steps(2);
assert_eq!(animated(&h), [true; 5], "each drag was one step");
}
/// A swatch (a small rect filled with `col`) painted this frame in the left half of `row`.
fn swatch_in(h: &Harness<'_, EffectcraftApp>, row: Rect, col: Color32) -> bool {
fn walk(s: &egui::Shape, row: Rect, col: Color32) -> bool {
match s {
egui::Shape::Rect(r) => r.fill == col && r.rect.width() < 16.0 && row.contains(r.rect.center()) && r.rect.center().x < row.center().x,
egui::Shape::Vec(v) => v.iter().any(|s| walk(s, row, col)),
_ => false,
}
}
h.output().shapes.iter().any(|c| walk(&c.shape, row, col))
}
/// Clicking a layer's label swatch opens the label menu, which shows each label's colour before
/// its name, so a label is picked by its colour (#290).
#[test]
fn the_label_swatch_menu_shows_the_label_colours() {
let (mut h, ids) = harness();
let green = h.state().session.prefs.label_name(Label::Green);
let at = rect(&h, &format!("timeline.layer.{}.label", ids[1])).center();
drag(&mut h, at, at, 1);
let entry = h.query_by_label(&green).expect("the label menu is open").rect();
for l in [Label::Green, Label::SeaFoam, Label::Purple] {
let e = h.query_by_label(&h.state().session.prefs.label_name(l)).unwrap().rect();
assert!(swatch_in(&h, e, h.state().tokens.label(l)), "no {l:?} swatch");
}
let undo = h.state().session.history.undo.len();
drag(&mut h, entry.center(), entry.center(), 1);
assert_eq!(h.state().session.active_comp().unwrap().layer(LayerId(ids[1])).unwrap().label, Label::Green);
assert_eq!(h.state().session.history.undo.len(), undo + 1);
assert!(h.query_by_label(&green).is_none(), "the menu closed after the choice");
}
/// A layer extended past its source's end (its last frame held) has that part of its bar
/// striped, so the source's own span shows (#290); with time remapping it isn't.
#[test]
fn the_bar_past_the_source_end_is_striped() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Inner", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap();
s.execute("comp.new", json!({"name": "Outer", "width": 64, "height": 64, "frameRate": 30, "duration": 4})).unwrap();
let l = s.execute("layer.addItem", json!({"item": "Inner"})).unwrap()["layer"].as_u64().unwrap();
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
let past = format!("timeline.layer.{l}.bar.afterSource");
assert!(h.state().auto.find(&past).is_none(), "the source fills the whole bar");
h.state_mut().session.execute("layer.timing", json!({"layers": [l], "out": 4.0})).unwrap();
h.run_steps(2);
let (bar, after) = (rect(&h, &format!("timeline.layer.{l}.bar")), rect(&h, &past));
assert!((after.min.x - bar.center().x).abs() < 2.0 && (after.max.x - bar.max.x).abs() < 1.0, "{bar:?} {after:?}");
h.state_mut().session.execute("layer.enableTimeRemap", json!({"layers": [l]})).unwrap();
h.run_steps(2);
assert!(h.state().auto.find(&past).is_none(), "time remapping decides what shows");
}
+30
View File
@@ -6,6 +6,7 @@ use effectcraft_engine::Session;
use effectcraft_ui_egui::EffectcraftApp;
use egui::{Event, Modifiers, MouseWheelUnit, Pos2, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
/// A 10-minute comp with one solid, zoomed in on its start.
@@ -170,6 +171,35 @@ fn spacebar_drag_scrolls_the_time_graph() {
assert!(in_point(&h) < -1.0, "{}", in_point(&h));
}
/// #290: right-clicking the work area bar offers its commands; Trim Comp to Work Area trims.
#[test]
fn work_area_context_menu_trims_the_comp() {
let (mut h, _) = harness();
h.state_mut().ui.timeline.pps = None;
h.state_mut().session.execute("comp.workArea", json!({"start": 60.0, "end": 180.0})).unwrap();
h.run_steps(3);
let at = rect(&h, "timeline.workArea.bar").center();
h.event(Event::PointerMoved(at));
h.step();
for pressed in [true, false] {
h.event(Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(2);
for label in ["Lift Work Area", "Extract Work Area"] {
assert!(h.query_by_label_contains(label).is_some(), "{label}");
}
let trim = h.query_by_label_contains("Trim Comp to Work Area").expect("Trim Comp to Work Area").rect().center();
h.event(Event::PointerMoved(trim));
h.step();
for pressed in [true, false] {
h.event(Event::PointerButton { pos: trim, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(2);
assert_eq!(h.state().session.active_comp().unwrap().duration.seconds(), 120.0);
}
/// #252: Shift-dragging a work area end, or the whole work area bar, snaps to the current-time
/// indicator when it comes within reach; without Shift it lands where the pointer is.
#[test]
+129
View File
@@ -479,6 +479,105 @@ fn motion_path_key_drag_edits_that_key() {
assert_eq!(k[0].value.as_vec3(), [100.0, 100.0, 0.0]);
}
/// The first mask path of layer `name`.
fn mask_path(h: &Harness<'_, EffectcraftApp>, name: &str) -> effectcraft_engine::keyframe::ShapePath {
let l = h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == name).unwrap().clone();
l.masks().unwrap().groups().next().unwrap().get("path").unwrap().value.as_path().unwrap().clone()
}
/// Drawing a mask with the Pen, moving a point and undoing take one action back per undo; a
/// double-click on a point selects all of them in a free-transform box, and dragging inside it
/// (or, once the box is gone, dragging a point) moves the whole mask (#290).
#[test]
fn pen_mask_undoes_step_by_step_and_double_click_moves_the_whole_mask() {
let mut h = harness();
let plate = layer_id(&h, "Plate");
h.state_mut().session.execute("layer.select", json!({"layers": [plate]})).unwrap();
h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap();
h.state_mut().ui.tool = Tool::Pen;
h.run_steps(2);
let corners = [[100.0, 60.0], [500.0, 60.0], [500.0, 300.0], [100.0, 300.0]];
for p in corners.iter().chain([&corners[0]]) {
let at = screen(&h, [p[0] as f32, p[1] as f32]);
click(&mut h, at);
}
let drawn = mask_path(&h, "Plate");
let near = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1.0 && (a[1] - b[1]).abs() < 1.0;
assert!(drawn.closed && drawn.vertices.len() == 4 && drawn.vertices.iter().zip(&corners).all(|(v, c)| near(*v, *c)), "{drawn:?}");
h.state_mut().ui.tool = Tool::Selection;
h.run_steps(2);
// Move one point: one undo step puts it back, the next reopens the path, the next removes the
// last point.
let (from, to) = (screen(&h, [500.0, 60.0]), screen(&h, [520.0, 80.0]));
drag(&mut h, from, to);
let moved = mask_path(&h, "Plate");
assert!(near(moved.vertices[1], [520.0, 80.0]) && moved.vertices[0] == drawn.vertices[0], "{moved:?}");
let undo = |h: &mut Harness<'_, EffectcraftApp>| {
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
mask_path(h, "Plate")
};
assert_eq!(undo(&mut h), drawn);
assert!(!undo(&mut h).closed);
assert_eq!(undo(&mut h).vertices.len(), 3);
for _ in 0..3 {
h.state_mut().session.execute("edit.redo", json!({})).unwrap();
}
h.run_steps(2);
// Double-click a point: all of them selected, in a free-transform box.
let at = screen(&h, [100.0, 300.0]);
h.input_mut().events.push(Event::PointerMoved(at));
h.step();
for pressed in [true, false, true, false] {
h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
}
h.run_steps(3);
assert_eq!(h.state().session.state.selected_vertices.len(), 4);
assert!(h.state().auto.find("viewer.freeTransform.handle.0").is_some(), "the free-transform box is up");
// Dragging inside the box moves the whole mask.
let (from, to) = (screen(&h, [300.0, 120.0]), screen(&h, [310.0, 130.0]));
drag(&mut h, from, to);
let path = mask_path(&h, "Plate");
assert!(path.vertices.iter().zip(&moved.vertices).all(|(v, c)| near(*v, [c[0] + 10.0, c[1] + 10.0])), "{path:?}");
// A click away ends the box; the points stay selected and dragging one moves them all.
let away = screen(&h, [600.0, 340.0]);
click(&mut h, away);
h.run_steps(2);
assert!(h.state().auto.find("viewer.freeTransform.handle.0").is_none());
let (from, to) = (screen(&h, [110.0, 310.0]), screen(&h, [100.0, 300.0]));
drag(&mut h, from, to);
let back = mask_path(&h, "Plate");
assert!(back.vertices.iter().zip(&moved.vertices).all(|(v, c)| near(*v, *c)), "{back:?}");
}
/// A straight two-key motion path shows Bezier handles at both keys (it had none), and dragging
/// one pulls the path into a curve (#290).
#[test]
fn a_straight_motion_path_has_handles_to_curve_it() {
let mut h = harness();
let box_id: LayerId = h.state().session.active_comp().unwrap().layers[0].id;
let s = &mut h.state_mut().session;
s.execute("layer.select", json!({"layers": [box_id.0]})).unwrap();
s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/position", "time": 0.0, "value": [100, 100, 0]})).unwrap();
s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/position", "time": 2.0, "value": [400, 100, 0]})).unwrap();
s.execute("view.snapping", json!({"value": false})).unwrap();
s.set_time(effectcraft_engine::time::Tick::from_seconds_f64(1.0));
h.run_steps(3);
let (out0, in1) = (format!("viewer.motionPath.{}.0.out", box_id.0), format!("viewer.motionPath.{}.1.in", box_id.0));
assert!(h.state().auto.find(&in1).is_some(), "the second key has a handle");
// The first key's handle lies a third of the way along the path: drag it down 150 px.
let from = rect(&h, &out0).center();
assert!(from.distance(screen(&h, [200.0, 100.0])) < 2.0, "{from:?}");
let to = screen(&h, [200.0, 250.0]);
drag(&mut h, from, to);
let p = h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/position").unwrap().clone();
assert!(!p.keys[0].spatial_auto);
let t = p.keys[0].spatial_out;
assert!((t[0] - 100.0).abs() < 2.0 && (t[1] - 150.0).abs() < 2.0, "{t:?}");
let mid = p.value_at(effectcraft_engine::time::Tick::from_seconds_f64(1.0)).as_vec3();
assert!(mid[1] > 130.0, "the path curves: {mid:?}");
}
#[test]
fn graph_editor_transform_box_scales_selected_keys_in_time() {
let mut h = harness();
@@ -1434,6 +1533,36 @@ fn handle_drags_snap_to_the_comp_edges() {
assert!(close(s, [100.0, 100.0]), "{s:?}");
}
/// The arrow keys over the Composition panel nudge the selected layer 1 pixel at the viewer's
/// magnification (half a comp pixel at 200 %), Shift+arrow 10, one undo step each (#290).
#[test]
fn arrow_keys_nudge_the_selected_layer_at_the_viewer_magnification() {
let mut h = harness();
let box_id = h.state().session.active_comp().unwrap().layers[0].id;
h.state_mut().session.execute("prop.set", json!({"layer": box_id.0, "path": "transform/position", "value": [100, 100, 0]})).unwrap();
h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap();
h.run_steps(2);
let at = screen(&h, [100.0, 100.0]);
click(&mut h, at);
assert_eq!(h.state().session.state.selected_layers, vec![box_id]);
let pos = |h: &Harness<'_, EffectcraftApp>| {
h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/position").unwrap().value.as_vec3()
};
let ppp = h.ctx.pixels_per_point();
h.state_mut().ui.viewer.zoom = Some(1.0 / ppp);
h.run_steps(2);
let undo = h.state().session.history.undo.len();
key(&mut h, egui::Key::ArrowRight, egui::Modifiers::NONE);
key(&mut h, egui::Key::ArrowDown, egui::Modifiers::SHIFT);
assert_eq!(pos(&h), [101.0, 110.0, 0.0]);
assert_eq!(h.state().session.history.undo.len(), undo + 2, "one undo step per press");
h.state_mut().ui.viewer.zoom = Some(2.0 / ppp);
h.run_steps(2);
key(&mut h, egui::Key::ArrowLeft, egui::Modifiers::NONE);
key(&mut h, egui::Key::ArrowUp, egui::Modifiers::NONE);
assert_eq!(pos(&h), [100.5, 109.5, 0.0], "sub-pixel steps when zoomed in");
}
/// A comp wider than the GPU's texture limit at Full resolution shows its frame (averaged down
/// into a texture the renderer accepts) instead of failing the upload (#201: 11000×2200).
#[test]
+1 -1
View File
File diff suppressed because one or more lines are too long