diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index acd8e9c8..eaf70cf7 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -1141,6 +1141,22 @@ pub(crate) fn update_position(tr: &mut PropGroup, lt: Tick, f: impl Fn([f64; 3]) } } +/// A Transform property's default (Reset) by match id: the anchor at the centre of the source +/// (`w`×`h`), the position at the comp's (`cw`×`ch`), 100% scale and opacity, no rotation. +pub(crate) fn transform_default(m: &str, (w, h): (f64, f64), (cw, ch): (f64, f64)) -> Option { + Some(match m { + "anchor" => KV::Vec3([w / 2.0, h / 2.0, 0.0]), + "position" => KV::Vec3([cw / 2.0, ch / 2.0, 0.0]), + "positionX" => KV::Scalar(cw / 2.0), + "positionY" => KV::Scalar(ch / 2.0), + "positionZ" | "rotation" | "rotationX" | "rotationY" => KV::Scalar(0.0), + "scale" => KV::Vec3([100.0; 3]), + "orientation" => KV::Vec3([0.0; 3]), + "opacity" => KV::Scalar(100.0), + _ => return None, + }) +} + fn transform_op(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; let ids = unlocked(s, cid, ids, "layer.transform")?; @@ -1163,14 +1179,12 @@ fn transform_op(s: &mut Session, p: &Value) -> Result { }; match op.as_str() { "reset" => { - set(tr, "anchor", KV::Vec3([w / 2.0, h / 2.0, 0.0])); + for m in ["anchor", "scale", "rotation", "rotationX", "rotationY", "orientation", "opacity"] { + if let Some(v) = transform_default(m, (w, h), (cw, ch)) { + set(tr, m, v); + } + } update_position(tr, lt, |_| [cw / 2.0, ch / 2.0, 0.0]); - set(tr, "scale", KV::Vec3([100.0; 3])); - set(tr, "rotation", KV::Scalar(0.0)); - set(tr, "rotationX", KV::Scalar(0.0)); - set(tr, "rotationY", KV::Scalar(0.0)); - set(tr, "orientation", KV::Vec3([0.0; 3])); - set(tr, "opacity", KV::Scalar(100.0)); } // Centred in the frame; a 3D layer keeps its depth. "center" => update_position(tr, lt, |p| [cw / 2.0, ch / 2.0, p[2]]), diff --git a/crates/engine/src/commands/link.rs b/crates/engine/src/commands/link.rs index 73697698..2eeb7c5a 100644 --- a/crates/engine/src/commands/link.rs +++ b/crates/engine/src/commands/link.rs @@ -164,6 +164,12 @@ fn pick_whip(s: &mut Session, p: &Value) -> Result { let r = reference(comp, from, target, tu, s.prefs.general.expression_pick_whip_compact).ok_or_else(|| bad("prop.pickWhip", "no such target property"))?; let (me, them) = (from.props.find(uid).ok_or(EngineError::NoComp)?, target.props.find(tu).ok_or(EngineError::NoComp)?); let (dm, dt) = (dims(from, me), dims(target, them)); + // Dropped on one of the target's values: that dimension (`position[0]`), as in After Effects. + let (r, dt) = match t.get("dimension").and_then(Value::as_u64) { + Some(d) if d < dt as u64 => (format!("{r}[{d}]"), 1), + Some(d) => return Err(bad("prop.pickWhip", format!("the target has {dt} dimension(s), no dimension {d}"))), + None => (r, dt), + }; // While the expression is being edited the reference goes in at the cursor, replacing the // selected text (After Effects): `expression` is the text being edited, `range` the // selection [start, end] in characters. @@ -200,7 +206,7 @@ pub fn specs() -> Vec { "Pick Whip (Link Property)", [], None, - "{layer?, path|prop, target: {layer, path|prop}, expression?, range?: [start, end]} → sets an AE reference expression (with `expression`: inserts it there, replacing the characters in `range`, default the end)", + "{layer?, path|prop, target: {layer, path|prop, dimension?}, expression?, range?: [start, end]} → sets an AE reference expression (`dimension`: one of the target's values, `position[0]`; with `expression`: inserts it there, replacing the characters in `range`, default the end)", has_layers, pick_whip ), diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 8ee546de..063b179f 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -271,13 +271,40 @@ fn set_expr(s: &mut Session, p: &Value) -> Result { Ok(r) } +/// A property's default value, when it has a known one: an effect parameter's, or a Transform +/// property's (not a camera's or light's). +fn default_value(s: &Session, cid: ItemId, lid: LayerId, uid: Uid) -> Option { + let comp = s.project.comp(cid)?; + let layer = comp.layer(lid)?; + let pr = layer.props.find(uid)?; + let effect = layer.props.node_chain(uid)?.into_iter().find_map(|n| match n { + effectcraft_project::Node::Group(g) => match &g.kind { + GroupKind::Effect { effect } => Some(effect.clone()), + _ => None, + }, + _ => None, + }); + if let Some(effect) = effect { + let ps = effectcraft_effects::find(&effect)?.params.iter().find(|ps| ps.id == pr.match_id)?; + return Some(effectcraft_effects::default_value(ps, effectcraft_render::effect_bounds(&s.project, comp, layer).0)); + } + if matches!(layer.source, effectcraft_project::LayerSource::Camera | effectcraft_project::LayerSource::Light { .. }) + || !layer.transform()?.props().any(|t| t.uid == uid) + { + return None; + } + let (w, h) = effectcraft_render::source_size(&s.project, layer); + super::layer::transform_default(&pr.match_id, (w as f64, h as f64), (comp.width as f64, comp.height as f64)) +} + fn reset(s: &mut Session, p: &Value) -> Result { let (cid, lid, uid) = prop_ref(s, p, "prop.reset")?; let def = p.get("default").cloned(); + let own = default_value(s, cid, lid, uid); with_prop(s, "Reset Property", None, cid, lid, uid, |pr, _| { pr.keys.clear(); pr.expr = None; - if let Some(d) = def.as_ref().and_then(|d| pr.value.coerce_json(d)) { + if let Some(d) = def.as_ref().and_then(|d| pr.value.coerce_json(d)).or(own) { pr.value = d; } Ok(Value::Null) @@ -783,7 +810,15 @@ pub fn specs() -> Vec { toggle_transform_key ), cmd!("prop.setExpression", "Add Expression", ["Animation"], Some("Alt+Shift+="), "{layer?, path|prop, expression?, enabled?}", has_layers, set_expr), - cmd!("prop.reset", "Reset Property", [], None, "{layer?, path|prop, default?}", has_layers, reset), + cmd!( + "prop.reset", + "Reset Property", + [], + None, + "{layer?, path|prop, default?} → removes keyframes and expression; the value becomes `default`, else the property's own (an effect parameter's, a Transform property's)", + has_layers, + reset + ), cmd!("prop.select", "Select Property", [], None, "{layer?, path|prop, add?, selectKeys?}", has_layers, select_prop), cmd!( "prop.convertExpressionToKeyframes", diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index a392a478..cdfbf8dd 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -482,6 +482,60 @@ fn pick_whip_generates_ae_reference_expressions() { assert!(s.execute("prop.pickWhip", json!({"layer": l, "path": "transform/opacity", "target": {"layer": l, "path": "transform/opacity"}})).is_err()); } +/// #407: Reset without a value gives a property its own default (a Transform property's, an +/// effect parameter's) besides removing its keyframes and expression. +#[test] +fn reset_gives_a_property_its_default() { + let (mut s, l) = setup(); + s.execute("effect.apply", json!({"layer": l, "effect": "Gaussian Blur"})).unwrap(); + let blur = prop(&s, l, "effects/#1/blurriness").value; + let set = [ + ("transform/anchor", json!([10, 20, 0])), + ("transform/position", json!([10, 20, 0])), + ("transform/scale", json!([50, 70, 100])), + ("transform/rotation", json!(30)), + ("transform/opacity", json!(40)), + ("effects/#1/blurriness", json!(25)), + ]; + for (path, v) in &set { + s.execute("prop.set", json!({"layer": l, "path": path, "value": v})).unwrap(); + } + animate(&mut s, l, "transform/rotation", &[(0.0, json!(10)), (1.0, json!(20))]); + s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "expression": "50"})).unwrap(); + for (path, _) in &set { + s.execute("prop.reset", json!({"layer": l, "path": path})).unwrap(); + } + let v = |s: &Session, path: &str| prop(s, l, path).value; + assert_eq!(v(&s, "transform/anchor"), KV::Vec3([50.0, 50.0, 0.0]), "the solid's centre"); + assert_eq!(v(&s, "transform/position"), KV::Vec3([200.0, 150.0, 0.0]), "the comp's centre"); + assert_eq!(v(&s, "transform/scale"), KV::Vec3([100.0; 3])); + assert_eq!(v(&s, "transform/rotation"), KV::Scalar(0.0)); + assert!(!prop(&s, l, "transform/rotation").is_animated()); + assert_eq!(v(&s, "transform/opacity"), KV::Scalar(100.0)); + assert!(prop(&s, l, "transform/opacity").expr.is_none()); + assert_eq!(v(&s, "effects/#1/blurriness"), blur); + // A given default still wins. + s.execute("prop.reset", json!({"layer": l, "path": "transform/opacity", "default": 30})).unwrap(); + assert_eq!(v(&s, "transform/opacity"), KV::Scalar(30.0)); +} + +/// #363: dropped on one of the target's values the pick whip picks that dimension +/// (`position[0]`), as in After Effects; a multi-dimension property follows it on every axis. +#[test] +fn pick_whip_picks_one_dimension_of_the_target() { + let (mut s, l) = setup(); + let o = s.execute("layer.newSolid", json!({"name": "Other", "color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap(); + let whip = |s: &mut Session, path: &str, d: u64| { + s.execute("prop.pickWhip", json!({"layer": l, "path": path, "target": {"layer": o, "path": "transform/position", "dimension": d}})) + }; + let r = "thisComp.layer(\"Other\").transform.position"; + assert_eq!(whip(&mut s, "transform/rotation", 0).unwrap(), json!(format!("{r}[0]"))); + assert_eq!(whip(&mut s, "transform/scale", 1).unwrap(), json!(format!("temp = {r}[1];\n[temp, temp]"))); + assert_eq!(prop(&s, l, "transform/scale").expr.unwrap().text, format!("temp = {r}[1];\n[temp, temp]")); + // A 2D layer's Position has two. + assert!(whip(&mut s, "transform/rotation", 2).is_err()); +} + /// #284: with the expression being edited, the pick whip puts the reference at the cursor, /// replacing the selected characters (After Effects), in one undo step. #[test] diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 9ba6ed55..ab8baace 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -159,7 +159,9 @@ impl EffectcraftApp { panels::precomp::navigator(self, &mut child, nav); content.min.y = nav.max.y; } - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(content).id_salt(("panel", p.id()))); + let mut child = ui.new_child( + egui::UiBuilder::new().max_rect(content).id_salt(("panel", p.id())).ui_stack_info(egui::UiStackInfo::default().with_tag(crate::widgets::PANEL_TAG)), + ); child.set_clip_rect(content.intersect(ui.clip_rect())); panels::show(self, &mut child, p, content); if viewer && !self.ui.start_screen { diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 279a437f..f41242f0 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -194,6 +194,17 @@ fn sequence_fields(app: &EffectcraftApp, runs: &[Vec]) -> Vec { ] } +/// Fields to type a numeric property value in (`value`, or `value[0]`… per dimension). +fn value_fields(name: &str, v: &effectcraft_engine::keyframe::Value) -> Option> { + use effectcraft_engine::keyframe::Value as KV; + Some(match v { + KV::Scalar(v) => vec![Field::num("value", name, *v)], + KV::Vec2(v) => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1])], + KV::Vec3(v) => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])], + _ => return None, + }) +} + /// If `id` is a dialog command invoked without its parameters, open its form and return true. pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { let s = &app.session; @@ -251,13 +262,14 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { let t = p.get("time").and_then(Value::as_f64).map(effectcraft_engine::time::Tick::from_seconds_f64); let Some((pr, t)) = pr.zip(t) else { return false }; let Some(k) = pr.keys.iter().min_by_key(|k| (k.time.0 - t.0).abs()) else { return false }; - use effectcraft_engine::keyframe::Value as KV; - let fields = match &k.value { - KV::Scalar(v) => vec![Field::num("value", &pr.name, *v)], - KV::Vec2(v) => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1])], - KV::Vec3(v) => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])], - _ => return false, - }; + let Some(fields) = value_fields(&pr.name, &k.value) else { return false }; + (pr.name.clone(), fields) + } + // A property's context menu ▸ Edit Value…: its value at the current time. + "prop.set" if !has(p, &["value"]) => { + let l = p.get("layer").and_then(Value::as_u64).and_then(|l| comp?.layer(effectcraft_engine::project::LayerId(l))); + let Some((l, pr)) = l.and_then(|l| Some((l, l.props.find(p.get("prop").and_then(Value::as_u64)?)?))) else { return false }; + let Some(fields) = value_fields(&pr.name, &pr.value_at(l.layer_time(t))) else { return false }; (pr.name.clone(), fields) } "layer.setTransform" if !has(p, &["value"]) => { diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 7369c634..7f19d91b 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -495,12 +495,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte app.ui.timeline.graph_transform_box = !tbox; } x += 6.0; - let pos_sel = app.session.state.selected_props.iter().find_map(|(l, u)| { - let layer = comp.layer(*l)?; - let tr = layer.transform()?; - (tr.get("position").is_some_and(|p| p.uid == *u) || ["positionX", "positionY", "positionZ"].iter().any(|m| tr.get(m).is_some_and(|p| p.uid == *u))) - .then_some(l.0) - }); + let pos_sel = app.session.state.selected_props.iter().find_map(|(l, u)| super::timeline::is_position(comp.layer(*l)?, *u).then_some(l.0)); if text_button(app, ui, p, &mut x, y, "Separate Dimensions", false, "separateDimensions", "Separate Dimensions (Position)") && let Some(l) = pos_sel.or(app.session.state.selected_layers.first().map(|l| l.0)) { diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 047966af..ab5a1c8f 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -84,6 +84,18 @@ pub fn timecode(session: &Session, comp: &Comp, t: Tick) -> String { effectcraft_engine::commands::time::display_time(session, comp, t) } +/// Ctrl/Cmd-click on a current-time display toggles the project's Time Display Style between +/// Timecode and Frames, as in After Effects (through Project Settings: one undo step). +pub(crate) fn toggle_time_display(app: &mut EffectcraftApp, resp: &egui::Response) { + if !(resp.clicked() && resp.ctx.input(|i| i.modifiers.command)) { + return; + } + let timecode = app.session.project.settings.time_display == effectcraft_engine::project::TimeDisplayStyle::Timecode; + if let Err(e) = app.session.execute("file.projectSettings", serde_json::json!({"timeDisplay": if timecode { "frames" } else { "timecode" }})) { + app.ui.status = e.to_string(); + } +} + /// Spacebar held outside a text field: the Hand tool (drags pan the viewer and scroll the /// Timeline). pub(crate) fn space_hand(ctx: &egui::Context) -> bool { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 69139d3b..23e1850e 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -93,14 +93,26 @@ fn stripes(p: &egui::Painter, r: Rect, label: Color32) { /// Row height (expression editors grow with their text). fn row_height(row: &Row, rh: f32) -> f32 { match row.kind { - RowKind::Expr { lines, .. } => rh * lines.clamp(1, 8) as f32 + 6.0, + RowKind::Expr { lines, .. } => rh * lines as f32 + 6.0, RowKind::Waveform { .. } => rh * 3.0, _ => rh, } } -/// Insert an expression editor row after every property that has an expression. -fn with_expr_rows(rows: Vec, comp: &Comp, closed: &std::collections::BTreeSet) -> Vec { +/// An inline expression editor grows with its text up to this many lines (After Effects)... +const EXPR_AUTO_LINES: usize = 8; +/// ...and its bottom edge drags it up to this many. +const EXPR_MAX_LINES: usize = 100; + +/// The text of the expression being edited (while its editor has the keyboard). +fn expr_buf_id(prop: u64) -> egui::Id { + egui::Id::new(("expr-buf", prop)) +} + +/// Insert an expression editor row after every property that has an expression: as many lines +/// as its text (the text being edited, so it grows while typing), up to 8, or the height its +/// bottom edge was dragged to. +fn with_expr_rows(rows: Vec, comp: &Comp, tl: &crate::state::TimelineState, ctx: &egui::Context) -> Vec { let mut out = Vec::with_capacity(rows.len()); // Rows come grouped by layer: look each layer up once (a linear search per property row // made twirling open many layers of a big comp quadratic). @@ -108,15 +120,17 @@ fn with_expr_rows(rows: Vec, comp: &Comp, closed: &std::collections::BTreeS for r in rows { let mut extra = None; if let RowKind::Prop { uid } = r.kind - && !closed.contains(&uid) + && !tl.expr_closed.contains(&uid) { if cur.is_none_or(|l| l.id != r.layer) { cur = comp.layer(r.layer); } - extra = cur.and_then(|l| l.props.find(uid)).and_then(|p| p.expr.as_ref()).map(|e| Row { - layer: r.layer, - depth: r.depth, - kind: RowKind::Expr { uid, lines: e.text.lines().count().max(1) }, + extra = cur.and_then(|l| l.props.find(uid)).and_then(|p| p.expr.as_ref()).map(|e| { + let lines = tl.expr_lines.get(&uid).copied().unwrap_or_else(|| { + let edited = ctx.data(|d| d.get_temp::(expr_buf_id(uid))); + edited.as_deref().unwrap_or(&e.text).split('\n').count().min(EXPR_AUTO_LINES) + }); + Row { layer: r.layer, depth: r.depth, kind: RowKind::Expr { uid, lines: lines.clamp(1, EXPR_MAX_LINES) } } }); } out.push(r); @@ -132,6 +146,20 @@ fn pick_whip_id() -> egui::Id { egui::Id::new("tl-pickwhip") } +/// Autoscroll speed (points per second, up negative) of a drag held at height `y` within +/// `zone` of the top or bottom edge of `rows`, or past it: faster the deeper. +fn edge_scroll_speed(rows: Rect, y: f32, zone: f32) -> f32 { + let zone = zone.min(rows.height() / 2.0).max(1.0); + let depth = if y < rows.min.y + zone { + y - (rows.min.y + zone) + } else if y > rows.max.y - zone { + y - (rows.max.y - zone) + } else { + return 0.0; + }; + (depth / zone).clamp(-3.0, 3.0) * 400.0 +} + /// The text of an expression being edited when its pick whip was pressed, and the selection in /// it (characters): the pick whip's reference goes in there. type WhipInto = (String, [usize; 2]); @@ -1156,9 +1184,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let tc_rect = Rect::from_min_size(pos2(rect.min.x + 12.0, top + 6.0), vec2(150.0, 24.0)); let tc_resp = ui.interact(tc_rect, egui::Id::new("tl-timecode"), Sense::click_and_drag()); p.text(tc_rect.left_center(), Align2::LEFT_CENTER, &tc, Tokens::semibold(16.0), t.timecode); - let sub = format!("{:05} ({:.2} fps)", fr.frame_at(time) + app.session.project.settings.frame_start, fr.as_f64()); - p.text(pos2(tc_rect.min.x, tc_rect.max.y + 8.0), Align2::LEFT_CENTER, sub, Tokens::ui(10.5), t.text_faint); + // Under it the other style (frames under timecode, timecode under frames) and the rate. + let settings = &app.session.project.settings; + let other = if settings.time_display == effectcraft_engine::project::TimeDisplayStyle::Timecode { + format!("{:05}", fr.frame_at(time) + settings.frame_start) + } else { + effectcraft_engine::time::format_timecode_ae(fr.frame_at(time + comp.display_start), fr, false) + }; + p.text(pos2(tc_rect.min.x, tc_rect.max.y + 8.0), Align2::LEFT_CENTER, format!("{other} ({:.2} fps)", fr.as_f64()), Tokens::ui(10.5), t.text_faint); app.auto.add("timeline.timecode", tc_rect, &tc); + super::toggle_time_display(app, &tc_resp); if tc_resp.dragged() { let d = tc_resp.drag_delta().x as f64; let f = fr.frame_at(time) + (d * 0.5).round() as i64; @@ -1521,7 +1556,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let _ = cw.end; // ---- rows. - let rows = with_expr_rows(build_rows(app, &comp), &comp, &app.ui.timeline.expr_closed); + let rows = with_expr_rows(build_rows(app, &comp), &comp, &app.ui.timeline, &ctx); let rh = t.row_h; let total_h: f32 = rows.iter().map(|r| row_height(r, rh)).sum(); let max_scroll = (total_h - rows_rect.height() + rh).max(0.0); @@ -1545,7 +1580,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } - app.ui.timeline.scroll_y = app.ui.timeline.scroll_y.min(max_scroll); + // A pick whip held near the top or bottom of the layers (or past it) scrolls them, as in + // After Effects. + if ctx.data(|d| d.get_temp::(pick_whip_id())).is_some() + && let Some(ptr) = ctx.input(|i| i.pointer.latest_pos()).filter(|p| rect.x_range().contains(p.x)) + { + let speed = edge_scroll_speed(rows_rect, ptr.y, rh); + if speed != 0.0 { + app.ui.timeline.scroll_y += speed * ctx.input(|i| i.stable_dt).min(0.1); + ctx.request_repaint(); + } + } + app.ui.timeline.scroll_y = app.ui.timeline.scroll_y.clamp(0.0, max_scroll); let snap_project = app.session.project.clone(); let snap_expr = app.session.expr.clone(); let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref(), footage: None }; @@ -1567,6 +1613,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let outline_empty = ui.interact(outline_rect, egui::Id::new("tl-layer-bg"), Sense::click_and_drag()); app.auto.add("timeline.layerMarquee", outline_rect, "Drag empty outline to select layers"); let mut hit_rows: Vec<(Rect, Row)> = vec![]; + // The value fields of multi-dimension properties (per dimension), for the pick whip. + let mut dim_hits: Vec<(u64, Vec)> = vec![]; for (ri, row) in rows.iter().enumerate() { // Outline widgets never spill into the time graph (narrow timelines). ui.set_clip_rect(left_clip); @@ -2074,6 +2122,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { RowKind::Expr { uid, lines } => { let Some(prop) = layer.props.find(*uid) else { continue }; let Some(ex) = prop.expr.clone() else { continue }; + let (buf_id, edit_id) = (expr_buf_id(*uid), egui::Id::new(("expr-edit", uid))); lp.rect_filled(left, 0.0, t.row_alt); let indent = cw.name + 6.0 + 14.0 * row.depth as f32; lp.text(pos2(indent + 12.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Expression:"), Tokens::ui(11.5), t.text_dim); @@ -2104,9 +2153,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Pressed while the expression is being edited (its text is buffered while // the editor has the focus): the reference will go in at the cursor, // replacing the selection (#284). - let into = ctx.data(|d| d.get_temp::(egui::Id::new(("expr-buf", uid)))).map(|buf| { + let into = ctx.data(|d| d.get_temp::(buf_id)).map(|buf| { let n = buf.chars().count(); - let sel = egui::text_edit::TextEditState::load(&ctx, egui::Id::new(("expr-edit", uid))) + let sel = egui::text_edit::TextEditState::load(&ctx, edit_id) .and_then(|st| st.cursor.char_range()) .map_or([n, n], |r| [r.primary.index.0.min(r.secondary.index.0).min(n), r.primary.index.0.max(r.secondary.index.0).min(n)]); (buf, sel) @@ -2153,8 +2202,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } }); if let Some(text) = picked { - let edit_id = egui::Id::new(("expr-edit", uid)); - let buf_id = egui::Id::new(("expr-buf", uid)); let cur: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone()); let at = egui::text_edit::TextEditState::load(&ctx, edit_id) .and_then(|st| st.cursor.char_range()) @@ -2171,8 +2218,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.set_clip_rect(right_clip); gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, Color32::from_rgb(0x1a, 0x1a, 0x1a)); let er = Rect::from_min_max(pos2(graph_x0 + 8.0, r.min.y + 3.0), pos2(rect.max.x - 14.0, r.max.y - 3.0)); - let buf_id = egui::Id::new(("expr-buf", uid)); - let edit_id = egui::Id::new(("expr-edit", uid)); let mut buf: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone()); if let Some(after) = ctx.data_mut(|d| d.remove_temp::(expr_refocus_id(*uid))) { // A pick whip just put a reference in: editing goes on in the new text, @@ -2185,8 +2230,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } // Settings ▸ Scripting & Expressions ▸ Expressions Editor. let sp = app.session.prefs.scripting.clone(); - let resp = super::expr_editor::editor(ui, edit_id, &mut buf, er, &sp, if ex.enabled { expr_col } else { t.text_dim }, (*lines).clamp(1, 8), &t); + let resp = super::expr_editor::editor(ui, edit_id, &mut buf, er, &sp, if ex.enabled { expr_col } else { t.text_dim }, *lines, &t); app.auto.add(&format!("timeline.prop.{uid}.expression"), er, &prop.name); + // Its bottom edge drags to set its height in whole lines, as in After Effects. + let edge = Rect::from_min_max(pos2(er.min.x, r.max.y - 4.0), pos2(er.max.x, r.max.y)); + let eresp = ui.interact(edge, egui::Id::new(("expr-edge", uid)), Sense::drag()); + app.auto.add(&format!("timeline.prop.{uid}.expressionHeight"), edge, "Expression field height"); + if eresp.hovered() || eresp.dragged() { + ctx.set_cursor_icon(egui::CursorIcon::ResizeVertical); + } + if let Some(py) = eresp.interact_pointer_pos().filter(|_| eresp.dragged()).map(|p| p.y) { + let n = ((py - r.min.y - 6.0) / rh).round().clamp(1.0, EXPR_MAX_LINES as f32); + app.ui.timeline.expr_lines.insert(*uid, n as usize); + } let commit = resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && (i.modifiers.command || i.modifiers.ctrl)); if commit { resp.surrender_focus(); @@ -2300,10 +2356,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if name_resp.clicked() { actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "add": ui.input(|i| i.modifiers.shift)}))); } + name_resp.context_menu(|ui| prop_menu(ui, &mut app.auto, cid, layer, prop, &mut actions)); // Value editors. let value = ectx.value(layer, prop); let vx = (cw.switches + 4.0).max(name_x + 120.0); - value_editor(app, ui, &lp, layer, prop, &value, pos2(vx, cy), &mut actions); + let dims = value_editor(app, ui, &lp, layer, prop, &value, pos2(vx, cy), &mut actions); + if !dims.is_empty() { + dim_hits.push((*uid, dims)); + } ui.set_clip_rect(right_clip); // Expression text row hint. if let Some(e) = prop.expr.as_ref().filter(|e| e.enabled && app.ui.timeline.expr_closed.contains(uid)) { @@ -2535,6 +2595,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } (1, Some((_, Row { layer, kind: RowKind::Prop { uid }, .. }))) if uid != src_prop => { let mut params = json!({"layer": src_layer, "prop": src_prop, "target": {"layer": layer.0, "prop": uid}}); + // On one of its values: that dimension (`position[0]`). + if let Some(d) = dim_hits.iter().find(|(u, _)| *u == uid).and_then(|(_, rs)| rs.iter().position(|r| r.contains(ptr))) { + params["target"]["dimension"] = json!(d); + } if let Some((text, [a, b])) = into { // Into the expression being edited, which goes on being edited. params["expression"] = json!(text); @@ -3180,6 +3244,43 @@ fn kind_name(k: MatteKind) -> &'static str { } } +/// Is property `uid` the layer's Position (or, separated, its X, Y or Z Position)? +pub(crate) fn is_position(layer: &Layer, uid: u64) -> bool { + layer.transform().is_some_and(|tr| ["position", "positionX", "positionY", "positionZ"].iter().any(|m| tr.get(m).is_some_and(|p| p.uid == uid))) +} + +/// A property's context menu (right-click its name in the Timeline), as in After Effects. +fn prop_menu( + ui: &mut egui::Ui, + auto: &mut crate::automation::Registry, + comp: ItemId, + layer: &Layer, + prop: &Property, + actions: &mut Vec<(String, serde_json::Value)>, +) { + let (l, uid) = (layer.id.0, prop.uid); + let mut item = |ui: &mut egui::Ui, key: &str, label: &str, selected: bool, cmd: &str, params: serde_json::Value| { + let resp = ui.add(egui::Button::new(label).selected(selected)); + auto.add(&format!("timeline.prop.{uid}.menu.{key}"), resp.rect, label); + if resp.clicked() { + actions.push((cmd.into(), params)); + ui.close(); + } + }; + item(ui, "reset", "Reset", false, "prop.reset", json!({"layer": l, "prop": uid})); + if matches!(prop.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) { + item(ui, "editValue", "Edit Value…", false, "prop.set", json!({"layer": l, "prop": uid})); + } + if is_position(layer, uid) { + let separated = layer.transform().is_some_and(|tr| tr.get("positionX").is_some()); + item(ui, "separateDimensions", "Separate Dimensions", separated, "prop.separateDimensions", json!({"layer": l, "value": !separated})); + } + ui.separator(); + let (key, label) = if prop.expr.is_some() { ("removeExpression", "Remove Expression") } else { ("addExpression", "Add Expression") }; + item(ui, key, label, false, "prop.setExpression", json!({"layer": l, "prop": uid})); + item(ui, "essentialGraphics", "Add Property to Essential Graphics", false, "essential.addProperty", json!({"comp": comp.0, "layer": l, "prop": uid})); +} + fn layer_context_menu(resp: &egui::Response, layer: &Layer, actions: &mut Vec<(String, serde_json::Value)>) { resp.context_menu(|ui| layer_menu(ui, &[layer.id.0], actions)); } @@ -3269,7 +3370,8 @@ fn constrained(c: &[f64], d: usize, v: f64) -> Vec { .collect() } -/// Inline value editor for a property row; pushes `prop.set` actions. +/// Inline value editor for a property row; pushes `prop.set` actions. Returns the rects of a +/// multi-dimension value's fields, one per dimension (none for other values). fn value_editor( app: &mut EffectcraftApp, ui: &mut egui::Ui, @@ -3279,9 +3381,10 @@ fn value_editor( value: &Value, at: Pos2, actions: &mut Vec<(String, serde_json::Value)>, -) { +) -> Vec { let t = app.tokens; let uid = prop.uid; + let mut dims = vec![]; let merge = format!("scrub-{uid}"); let set = |actions: &mut Vec<(String, serde_json::Value)>, v: serde_json::Value| { actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge}))) @@ -3336,6 +3439,7 @@ fn value_editor( let suffix = if pct && d + 1 == n { "%" } else { "" }; let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, y), egui::Id::new(("v", uid, d)), c[d], if pct { 0.5 } else { 1.0 }, range, 1, suffix, &t); app.auto.add(&format!("timeline.prop.{uid}.value.{d}"), r, &prop.name); + dims.push(r); if let Some(nv) = nv { let mut nc = c.clone(); // Alt edits one value of a linked pair. @@ -3418,6 +3522,7 @@ fn value_editor( p.text(pos2(x, at.y), Align2::LEFT_CENTER, s.chars().take(30).collect::(), Tokens::ui(12.0), t.text_dim); } } + dims } #[cfg(test)] diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index e63d9c7f..66045ce2 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -295,6 +295,32 @@ fn handle_point(b: [f64; 4], handle: usize) -> [f64; 2] { } } +/// The resize cursor over bounding-box handle `handle` of the on-screen box `sq` (corners +/// clockwise from the top left, handles as in [`handle_point`]): along the direction the handle +/// scales, so it turns with the layer, as in After Effects. A corner points between its two +/// edges (the box's diagonal whatever its proportions), an edge handle across its edge. +fn handle_cursor(sq: &[Pos2], handle: usize) -> egui::CursorIcon { + let at = |k: usize| sq.get(k % 4).copied().unwrap_or(Pos2::ZERO); + let dir = if handle < 4 { + let c = at(handle); + (c - at(handle + 3)).normalized() + (c - at(handle + 1)).normalized() + } else { + let e = at(handle - 3) - at(handle - 4); + vec2(-e.y, e.x) + }; + // Screen y points down: 45° is the south-east diagonal. + let a = dir.y.atan2(dir.x).to_degrees().rem_euclid(180.0); + if !(22.5..157.5).contains(&a) { + egui::CursorIcon::ResizeHorizontal + } else if a < 67.5 { + egui::CursorIcon::ResizeNwSe + } else if a < 112.5 { + egui::CursorIcon::ResizeVertical + } else { + egui::CursorIcon::ResizeNeSw + } +} + /// The scale ratio (x, y) that puts the point grabbed at `start` (relative to the anchor, layer /// pixels) under the pointer at `cur`. `uniform` (Shift on a corner) keeps the aspect ratio, /// following the pointer along the handle's diagonal. Dragging past the anchor flips the layer, @@ -748,7 +774,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } // Overlays + interaction. let selected = app.session.state.selected_layers.clone(); - let mut handle_hits: Vec<(LayerId, usize, Pos2)> = Vec::new(); + // Bounding-box handles: (layer, handle, screen point, its resize cursor). + let mut handle_hits: Vec<(LayerId, usize, Pos2, egui::CursorIcon)> = Vec::new(); let mut vertex_hits: Vec = Vec::new(); let mut key_hits: Vec = Vec::new(); let mut paths: Vec = Vec::new(); @@ -815,7 +842,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let hr = Rect::from_center_size(hp, vec2(hs, hs)); painter.rect_filled(hr, 0.0, col); painter.rect_stroke(hr, 0.0, Stroke::new(1.0, Color32::from_black_alpha(120)), StrokeKind::Outside); - handle_hits.push((l.id, i, hp)); + handle_hits.push((l.id, i, hp, handle_cursor(&sq, i))); app.auto.add(&format!("viewer.handle.{}.{i}", l.id.0), hr, "handle"); } // Anchor point. @@ -1036,8 +1063,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { egui::CursorIcon::Move } else if let Some((_, vertical)) = vt::guide_at(app, &comp, &map, hp) { if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical } - } else if handle_hits.iter().any(|(_, _, h)| h.distance(hp) < HANDLE) { - egui::CursorIcon::ResizeNwSe + } else if let Some((.., cursor)) = handle_hits.iter().find(|(_, _, h, _)| h.distance(hp) < HANDLE) { + *cursor } else { egui::CursorIcon::Default } @@ -1213,7 +1240,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // far enough to count as a drag it may have left a small handle. Tool::Selection => { let cpt = map.to_comp(press); - if let Some((lid, hi, _)) = handle_hits.iter().find(|(_, _, h)| h.distance(press) < HANDLE + 2.0).cloned() { + if let Some((lid, hi, ..)) = handle_hits.iter().find(|(_, _, h, _)| h.distance(press) < HANDLE + 2.0).cloned() { let layer = comp.layer(lid).cloned(); layer.and_then(|layer| { let tr = layer.transform()?; diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 06b529b3..36b64b48 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -733,7 +733,9 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, let g = p.layout_no_wrap(tc, Tokens::mono(12.0), t.timecode); let tr = Rect::from_min_size(pos2(x + 4.0, cy - 9.0), g.size() + vec2(8.0, 4.0)); p.galley(pos2(tr.min.x + 4.0, tr.min.y + 2.0), g, t.timecode); + let resp = ui.interact(tr, egui::Id::new("vw-timecode"), Sense::click()); app.auto.add("viewer.timecode", tr, "Current time"); + crate::panels::toggle_time_display(app, &resp); // Right: render time. let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0); if tr.max.x + 50.0 < bar.max.x { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index d7a7edcd..a7069c2b 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -413,6 +413,10 @@ pub struct TimelineState { /// Properties whose inline expression editor is collapsed. #[serde(default)] pub expr_closed: BTreeSet, + /// Inline expression editors given a height (in lines) by dragging their bottom edge; + /// the others grow with their text, up to 8 lines. + #[serde(default)] + pub expr_lines: BTreeMap, /// Scale and Mask Feather properties whose chain link (Constrain Proportions, on by default) /// was turned off. #[serde(default)] @@ -462,6 +466,7 @@ impl Default for TimelineState { graph_reference: false, graph_transform_box: true, expr_closed: BTreeSet::new(), + expr_lines: BTreeMap::new(), unlinked: BTreeSet::new(), columns: default_tl_columns(), source_name: false, diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 003cb9ad..8f12e52b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -6,7 +6,49 @@ use crate::automation::Registry; use crate::icons::{self, Icon}; use crate::theme::Tokens; -/// "Hot text": a blue number you drag horizontally to scrub, click to type. +/// The [`egui::UiStackInfo`] tag of a dock panel's `Ui`: Tab moves between the hot-text fields +/// of one panel. +pub const PANEL_TAG: &str = "effectcraft.panel"; + +/// A panel's hot-text fields in drawing order (this pass's and the last one's), and a Tab +/// pressed while typing in one: (that field, Shift held, the pass). +#[derive(Clone, Default)] +struct TabOrder { + pass: u64, + current: Vec, + last: Vec, + pending: Option<(egui::Id, bool, u64)>, +} + +fn tab_order_id(ui: &Ui) -> egui::Id { + let panel = ui.stack().iter().find(|s| s.tags().contains(PANEL_TAG)).map_or(egui::Id::NULL, |s| s.id); + egui::Id::new(("hot-tab-order", panel)) +} + +/// Puts hot-text field `id` in its panel's Tab order. True when a Tab in the field before it +/// (Shift+Tab: after it) moves the typing here. +fn tab_stop(ui: &Ui, id: egui::Id) -> bool { + let key = tab_order_id(ui); + let pass = ui.ctx().cumulative_pass_nr(); + ui.data_mut(|d| { + let o = d.get_temp_mut_or_default::(key); + if o.pass != pass { + o.last = std::mem::take(&mut o.current); + o.pass = pass; + } + o.current.push(id); + let Some((from, back, at)) = o.pending else { return false }; + let i = o.last.iter().position(|f| *f == from); + let next = i.and_then(|i| if back { i.checked_sub(1) } else { i.checked_add(1) }).and_then(|i| o.last.get(i)).copied(); + if next == Some(id) || next.is_none() || pass > at.saturating_add(2) { + o.pending = None; + } + next == Some(id) + }) +} + +/// "Hot text": a blue number you drag horizontally to scrub, click to type. Tab / Shift+Tab +/// while typing commits and types in the panel's next / previous one, as in After Effects. /// Returns (response, Some(new value) when changed, drag finished). pub fn hot_number_at( ui: &mut Ui, @@ -20,16 +62,28 @@ pub fn hot_number_at( t: &Tokens, ) -> (Rect, Option, bool) { let editing_id = id.with("editing"); - let editing: Option = ui.data(|d| d.get_temp(editing_id)); + let mut editing: Option = ui.data(|d| d.get_temp(editing_id)); + if tab_stop(ui, id) && editing.is_none() { + editing = Some(format!("{value:.decimals$}")); + } let text = format!("{value:.decimals$}{suffix}"); let galley = ui.painter().layout_no_wrap(text, Tokens::ui(12.0), t.hot_text); let rect = Rect::from_min_size(rect_min, galley.size() + vec2(4.0, 4.0)); if let Some(mut buf) = editing { let er = Rect::from_min_size(rect_min - vec2(2.0, 1.0), vec2((galley.size().x + 24.0).max(54.0), 18.0)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(er)); - let r = child.add(egui::TextEdit::singleline(&mut buf).desired_width(er.width()).font(Tokens::ui(12.0)).margin(egui::Margin::symmetric(2, 1))); + // (Tab stays with the field: it moves to the next hot-text field, not the next widget.) + let r = child.add( + egui::TextEdit::singleline(&mut buf) + .id(id.with("text")) + .desired_width(er.width()) + .font(Tokens::ui(12.0)) + .margin(egui::Margin::symmetric(2, 1)) + .lock_focus(true), + ); + let tab = r.has_focus().then(|| ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab).then_some(i.modifiers.shift))).flatten(); let mut out = None; - if r.lost_focus() { + if r.lost_focus() || tab.is_some() { let txt = buf.trim().trim_end_matches(suffix).trim().replace(',', "."); if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && let Ok(v) = txt.parse::() @@ -38,6 +92,11 @@ pub fn hot_number_at( out = Some(v.clamp(range.0, range.1)); } ui.data_mut(|d| d.remove::(editing_id)); + if let Some(back) = tab { + r.surrender_focus(); + let (key, pass) = (tab_order_id(ui), ui.ctx().cumulative_pass_nr()); + ui.data_mut(|d| d.get_temp_mut_or_default::(key).pending = Some((id, back, pass))); + } } else { keep_focus(ui.ctx(), &r, &buf); ui.data_mut(|d| d.insert_temp(editing_id, buf)); @@ -97,11 +156,13 @@ pub fn hot_int_at(ui: &mut Ui, rect_min: egui::Pos2, id: egui::Id, value: i64, s /// Keep the keyboard on an inline editor that is open until it loses the focus. The frame it /// opens (it has no focus yet) its whole `text` is selected, so typing replaces the value. +/// (Only then is the focus requested: a request resets what keys the field keeps for itself, +/// such as Tab with [`egui::TextEdit::lock_focus`].) pub fn keep_focus(ctx: &egui::Context, field: &Response, text: &str) { if !ctx.memory(|m| m.has_focus(field.id)) { select_all(ctx, field.id, text); + field.request_focus(); } - field.request_focus(); } /// Select all of the text field `id`'s `text`, so typing replaces it. diff --git a/crates/ui-egui/tests/ui_blank_context_menus.rs b/crates/ui-egui/tests/ui_blank_context_menus.rs index 415eddc7..561f50a0 100644 --- a/crates/ui-egui/tests/ui_blank_context_menus.rs +++ b/crates/ui-egui/tests/ui_blank_context_menus.rs @@ -1,4 +1,4 @@ -//! Right-click actions in the blank Project and Timeline areas. +//! Right-click actions in the blank Project and Timeline areas, and on Timeline property names. use effectcraft_engine::Session; use effectcraft_ui_egui::{Dialog, EffectcraftApp}; use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; @@ -131,3 +131,67 @@ fn timeline_blank_menu_opens_composition_settings() { h.run_steps(3); assert_eq!(h.state().dialog, Some(Dialog::CompSettings)); } + +/// #407: right-clicking a property's name in the Timeline opens After Effects' property menu: +/// Reset, Edit Value…, Separate Dimensions (Position only, checked once separated), Add or +/// Remove Expression and Add Property to Essential Graphics. +#[test] +fn timeline_property_names_have_the_property_menu() { + let mut h = harness(); + let layer = h.state_mut().session.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + h.state_mut().session.execute("prop.set", json!({"layer": layer, "path": "transform/rotation", "value": 30})).unwrap(); + let props = + |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.clone(); + let uid = |h: &Harness<'_, EffectcraftApp>, path: &str| props(h).prop(path).unwrap().uid; + h.state_mut().ui.timeline.open_layers.insert(layer); + let transform = props(&h).sub("transform").unwrap().uid; + h.state_mut().ui.timeline.open_groups.insert(transform); + h.run_steps(3); + let menu = |h: &mut Harness<'_, EffectcraftApp>, uid: u64| { + let p = rect(h, &format!("timeline.prop.{uid}.name")).center(); + right_click(h, p); + }; + let choose = |h: &mut Harness<'_, EffectcraftApp>, uid: u64, entry: &str| { + let p = rect(h, &format!("timeline.prop.{uid}.menu.{entry}")).center(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + }; + // Reset: Rotation's default. + let rotation = uid(&h, "transform/rotation"); + menu(&mut h, rotation); + assert!(h.query_by_label("Edit Value…").is_some()); + assert!(h.query_by_label("Separate Dimensions").is_none(), "only Position separates"); + choose(&mut h, rotation, "reset"); + assert_eq!(props(&h).prop("transform/rotation").unwrap().value.as_f64(), 0.0); + // Separate Dimensions on Position; X Position's menu has it too, and joins them again. + let position = uid(&h, "transform/position"); + menu(&mut h, position); + choose(&mut h, position, "separateDimensions"); + assert!(props(&h).prop("transform/positionX").is_some(), "separated"); + let x = uid(&h, "transform/positionX"); + menu(&mut h, x); + choose(&mut h, x, "separateDimensions"); + assert!(props(&h).prop("transform/positionX").is_none(), "joined"); + // Add Expression, then Remove Expression. + let opacity = uid(&h, "transform/opacity"); + menu(&mut h, opacity); + choose(&mut h, opacity, "addExpression"); + assert_eq!(props(&h).prop("transform/opacity").unwrap().expr.as_ref().map(|e| e.text.clone()).as_deref(), Some("transform.opacity")); + menu(&mut h, opacity); + choose(&mut h, opacity, "removeExpression"); + assert!(props(&h).prop("transform/opacity").unwrap().expr.is_none()); + // Add Property to Essential Graphics. + menu(&mut h, opacity); + choose(&mut h, opacity, "essentialGraphics"); + let comp = h.state().session.active_comp_id().unwrap(); + assert_eq!(h.state().session.project.comp(comp).unwrap().essential.as_ref().map_or(0, |e| e.controls.len()), 1); + // Edit Value… opens the value dialog. + let scale = uid(&h, "transform/scale"); + menu(&mut h, scale); + choose(&mut h, scale, "editValue"); + assert_eq!(h.state().dialog, Some(Dialog::Form)); +} diff --git a/crates/ui-egui/tests/ui_inline_edit.rs b/crates/ui-egui/tests/ui_inline_edit.rs index 47b77471..420e9635 100644 --- a/crates/ui-egui/tests/ui_inline_edit.rs +++ b/crates/ui-egui/tests/ui_inline_edit.rs @@ -127,3 +127,251 @@ fn the_expression_pick_whip_inserts_at_the_cursor_while_editing() { whip(&mut h); assert_eq!(expr(&h), "transform.rotation"); } + +/// #363: dropped on one of a property's values the expression pick whip picks that dimension +/// (`position[1]`), as in After Effects; on its name, the whole property. +#[test] +fn the_expression_pick_whip_picks_the_dimension_it_is_dropped_on() { + let (mut h, _) = harness(); + let (layer, opacity, position, transform) = { + let s = &mut h.state_mut().session; + let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "expression": "50"})).unwrap(); + let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.sub("transform").unwrap().clone(); + (l, tr.get("opacity").unwrap().uid, tr.get("position").unwrap().uid, tr.uid) + }; + h.state_mut().ui.timeline.open_layers.insert(layer); + h.state_mut().ui.timeline.open_groups.insert(transform); + h.run_steps(3); + let expr = |h: &Harness<'_, EffectcraftApp>| { + let comp = h.state().session.active_comp().unwrap(); + comp.layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.find(opacity).unwrap().expr.as_ref().unwrap().text.clone() + }; + let whip_to = |h: &mut Harness<'_, EffectcraftApp>, target: &str| { + let (from, to) = (center(h, &format!("timeline.prop.{opacity}.pickWhip")), center(h, target)); + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 8.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + }; + whip_to(&mut h, &format!("timeline.prop.{position}.value.1")); + assert_eq!(expr(&h), "transform.position[1]"); + whip_to(&mut h, &format!("timeline.prop.{position}.name")); + assert_eq!(expr(&h), "transform.position[0]", "the whole Position into one value: its first, as before"); +} + +/// #362: an expression pick whip held near the bottom or top of the Timeline's layers scrolls +/// them (After Effects), so a property out of view can be picked. +#[test] +fn the_pick_whip_scrolls_the_timeline_at_its_edges() { + let (mut h, _) = harness(); + let (top, opacity, transform) = { + let s = &mut h.state_mut().session; + for i in 0..40 { + s.execute("layer.newSolid", json!({"name": format!("Layer {i}"), "color": "#406080"})).unwrap(); + } + let l = s.execute("layer.newSolid", json!({"name": "Top", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "expression": "50"})).unwrap(); + let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.sub("transform").unwrap().clone(); + (l, tr.get("opacity").unwrap().uid, tr.uid) + }; + h.state_mut().ui.timeline.open_layers.insert(top); + h.state_mut().ui.timeline.open_groups.insert(transform); + h.run_steps(3); + let scroll = |h: &Harness<'_, EffectcraftApp>| h.state().ui.timeline.scroll_y; + let rows = h.state().auto.find("timeline.scroll").unwrap().rect; + let from = center(&h, &format!("timeline.prop.{opacity}.pickWhip")); + let (bottom, upper) = (pos2(from.x, rows[1] + rows[3] - 2.0), pos2(from.x, rows[1] + 2.0)); + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(from + (bottom - from) * (k as f32 / 8.0))); + h.step(); + } + h.run_steps(20); + let down = scroll(&h); + assert!(down > 50.0, "held at the bottom edge the layers scroll up: {down}"); + h.input_mut().events.push(Event::PointerMoved(upper)); + h.run_steps(10); + assert!(scroll(&h) < down, "and at the top edge back down: {} after {down}", scroll(&h)); + h.input_mut().events.push(Event::PointerMoved(pos2(from.x, rows[1] + rows[3] / 2.0))); + h.run_steps(2); + let still = scroll(&h); + h.run_steps(10); + assert_eq!(scroll(&h), still, "away from the edges it stays"); + h.input_mut().events.push(Event::PointerButton { pos: upper, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// #371: the inline expression field grows with the text being typed (it used to keep the +/// saved text's height until committed), and its bottom edge drags to any height in lines. +#[test] +fn the_expression_field_grows_while_editing_and_drags_taller() { + let (mut h, _) = harness(); + let (layer, opacity, transform) = { + let s = &mut h.state_mut().session; + let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "expression": "50"})).unwrap(); + let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.sub("transform").unwrap().clone(); + (l, tr.get("opacity").unwrap().uid, tr.uid) + }; + h.state_mut().ui.timeline.open_layers.insert(layer); + h.state_mut().ui.timeline.open_groups.insert(transform); + h.run_steps(3); + let id = format!("timeline.prop.{opacity}.expression"); + let height = |h: &Harness<'_, EffectcraftApp>| h.state().auto.find(&id).unwrap().rect[3]; + let line = h.state().tokens.row_h; + let one = height(&h); + let p = center(&h, &id); + click(&mut h, p, 1); + key(&mut h, Key::End); + for more in ["+ 1", "+ 2"] { + key(&mut h, Key::Enter); + type_text(&mut h, more); + } + h.run_steps(2); + assert!((height(&h) - one - 2.0 * line).abs() < 0.5, "three lines while editing: {} from {one}", height(&h)); + // Its bottom edge: dragged down 4 lines, then up past the top (one line). + let edge_id = format!("timeline.prop.{opacity}.expressionHeight"); + let drag_edge = |h: &mut Harness<'_, EffectcraftApp>, dy: f32| { + let edge = center(h, &edge_id); + let to = edge + egui::vec2(0.0, dy); + h.input_mut().events.push(Event::PointerMoved(edge)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: edge, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=6 { + h.input_mut().events.push(Event::PointerMoved(edge + (to - edge) * (k as f32 / 6.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + }; + drag_edge(&mut h, 4.0 * line); + assert_eq!(h.state().ui.timeline.expr_lines.get(&opacity), Some(&7)); + assert!((height(&h) - one - 6.0 * line).abs() < 0.5, "{}", height(&h)); + drag_edge(&mut h, -20.0 * line); + assert_eq!(h.state().ui.timeline.expr_lines.get(&opacity), Some(&1)); +} + +fn key_with(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) { + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }); + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); +} + +/// #361: Tab in a value being typed commits it and types in the next value (the next dimension, +/// then the next property's), Shift+Tab the previous, in the Timeline and in Effect Controls, +/// as in After Effects. It used to drop the keyboard instead. +#[test] +fn tab_commits_a_value_and_moves_to_the_next() { + let (mut h, _) = harness(); + let (layer, transform) = { + let s = &mut h.state_mut().session; + let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layers": [l]})).unwrap(); + for fx in ["Point Control", "Slider Control"] { + s.execute("effect.apply", json!({"layers": [l], "effect": fx})).unwrap(); + } + let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.sub("transform").unwrap().uid; + (l, tr) + }; + h.state_mut().ui.timeline.open_layers.insert(layer); + h.state_mut().ui.timeline.open_groups.insert(transform); + h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::EffectControls); + h.run_steps(3); + let props = + |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.clone(); + let value = |h: &Harness<'_, EffectcraftApp>, path: &str| props(h).prop(path).unwrap().value.components(); + let uid = |h: &Harness<'_, EffectcraftApp>, path: &str| props(h).prop(path).unwrap().uid; + let (position, scale) = (uid(&h, "transform/position"), uid(&h, "transform/scale")); + let none = Modifiers::NONE; + // Timeline: Position X, Tab, Position Y, Tab, Scale (linked). + let p = center(&h, &format!("timeline.prop.{position}.value.0")); + click(&mut h, p, 1); + type_text(&mut h, "100"); + key_with(&mut h, Key::Tab, none); + type_text(&mut h, "50"); + key_with(&mut h, Key::Tab, none); + type_text(&mut h, "200"); + key(&mut h, Key::Enter); + assert_eq!(value(&h, "transform/position")[..2], [100.0, 50.0]); + assert_eq!(value(&h, "transform/scale")[..2], [200.0, 200.0]); + assert!(!h.ctx.egui_wants_keyboard_input(), "Enter ends the typing"); + // Shift+Tab: from Scale X back to Position Y. + let p = center(&h, &format!("timeline.prop.{scale}.value.0")); + click(&mut h, p, 1); + type_text(&mut h, "120"); + key_with(&mut h, Key::Tab, Modifiers::SHIFT); + type_text(&mut h, "60"); + key(&mut h, Key::Enter); + assert_eq!(value(&h, "transform/position")[..2], [100.0, 60.0]); + assert_eq!(value(&h, "transform/scale")[..2], [120.0, 120.0]); + // Effect Controls: Point Control's Y, Tab, Slider Control's Slider (the next effect). + let point = uid(&h, "effects/#1/point"); + let p = center(&h, &format!("effectControls.prop.{point}.value.1")); + click(&mut h, p, 1); + type_text(&mut h, "7"); + key_with(&mut h, Key::Tab, none); + type_text(&mut h, "42"); + key(&mut h, Key::Enter); + assert_eq!(value(&h, "effects/#1/point")[1], 7.0); + assert_eq!(value(&h, "effects/#2/slider"), [42.0]); +} + +/// A click at `p` with `modifiers` held. +fn click_with(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, modifiers: Modifiers) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers }); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers }); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(3); +} + +/// #370: Ctrl/Cmd-click on the current-time display (the Timeline's or the Composition +/// panel's) toggles the Time Display Style between Timecode and Frames, one undo step each, as +/// in After Effects; under the Timeline's display the other style shows. +#[test] +fn ctrl_click_on_the_time_display_toggles_timecode_and_frames() { + use effectcraft_engine::project::TimeDisplayStyle; + let (mut h, _) = harness(); + h.state_mut().session.execute("time.set", json!({"frame": 30})).unwrap(); + h.run_steps(2); + let shown = |h: &Harness<'_, EffectcraftApp>, id: &str| h.state().auto.find(id).unwrap().label.clone(); + let style = |h: &Harness<'_, EffectcraftApp>| h.state().session.project.settings.time_display; + let undo = h.state().session.history.undo.len(); + let at = center(&h, "timeline.timecode"); + click_with(&mut h, at, Modifiers::COMMAND); + assert_eq!(style(&h), TimeDisplayStyle::Frames); + assert_eq!(shown(&h, "timeline.timecode"), "00030"); + assert_eq!(h.state().session.history.undo.len(), undo + 1, "one undo step"); + assert!(!h.ctx.egui_wants_keyboard_input(), "no time entry opened"); + let at = center(&h, "viewer.timecode"); + click_with(&mut h, at, Modifiers::COMMAND); + assert_eq!(style(&h), TimeDisplayStyle::Timecode); + assert_eq!(shown(&h, "timeline.timecode"), "0:00:01:06"); + // A plain click changes nothing. + click_with(&mut h, at, Modifiers::NONE); + let at = center(&h, "timeline.timecode"); + click_with(&mut h, at, Modifiers::NONE); + assert_eq!(style(&h), TimeDisplayStyle::Timecode); + // Feet + Frames goes to Timecode. + h.state_mut().session.execute("file.projectSettings", json!({"timeDisplay": "feet35"})).unwrap(); + h.run_steps(2); + click_with(&mut h, at, Modifiers::COMMAND); + assert_eq!(style(&h), TimeDisplayStyle::Timecode); +} diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 34b1ed3a..cfb1db37 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -1538,6 +1538,38 @@ fn handle_drags_snap_to_the_comp_edges() { assert!(close(s, [100.0, 100.0]), "{s:?}"); } +/// Each bounding-box handle shows the resize cursor along the direction it scales, turning with +/// the layer (#393: every handle showed the north-west / south-east cursor). +#[test] +fn handle_cursors_follow_each_handle_and_the_layer_rotation() { + use egui::CursorIcon::{ResizeHorizontal as H, ResizeNeSw as NeSw, ResizeNwSe as NwSe, ResizeVertical as V}; + let mut h = harness(); + let box_id = h.state().session.active_comp().unwrap().layers[0].id.0; + let set = |h: &mut Harness<'_, EffectcraftApp>, path: &str, v: serde_json::Value| { + h.state_mut().session.execute("prop.set", json!({"layer": box_id, "path": path, "value": v})).unwrap(); + h.run_steps(2); + }; + let cursors = |h: &mut Harness<'_, EffectcraftApp>| { + (0..8) + .map(|i| { + let c = rect(h, &format!("viewer.handle.{box_id}.{i}")).center(); + h.input_mut().events.push(Event::PointerMoved(c)); + h.run_steps(2); + h.output().platform_output.cursor_icon + }) + .collect::>() + }; + h.state_mut().session.execute("layer.select", json!({"layers": [box_id]})).unwrap(); + // A wide box: its corners still take the diagonal cursors. + set(&mut h, "transform/scale", json!([300, 100, 100])); + // Corners clockwise from the top left, then the top, right, bottom and left edges. + assert_eq!(cursors(&mut h), [NwSe, NeSw, NwSe, NeSw, V, H, V, H]); + set(&mut h, "transform/rotation", json!(90)); + assert_eq!(cursors(&mut h), [NeSw, NwSe, NeSw, NwSe, H, V, H, V], "turned a quarter"); + set(&mut h, "transform/rotation", json!(45)); + assert_eq!(cursors(&mut h), [V, H, V, H, NeSw, NwSe, NeSw, NwSe], "turned an eighth"); +} + /// 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] diff --git a/docs/agents.md b/docs/agents.md index 207406f5..71d52afe 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -482,6 +482,17 @@ Extension points and the bundled extensions: [plugins.md](plugins.md). step (separated X/Y/Z Position; Orientation and X/Y/Z Rotation on 3D layers). * Rename a layer with Enter (or double-click a name): the whole name is selected, Enter or a click elsewhere commits, Escape cancels; `layer.rename {layer, name}` does it directly. +* Ctrl/Cmd-click on the current-time display (Timeline or Composition panel) toggles the Time + Display Style between Timecode and Frames: `file.projectSettings {"timeDisplay": "frames"}`. +* `prop.pickWhip {layer, prop, target: {layer, prop, dimension?}}` links a property as its + expression pick whip does; `dimension` picks one of the target's values (`position[0]`), as a + drop on that value field does. +* A property's menu (right-click its name; entries `timeline.prop..menu.`): Reset + (`prop.reset`: no keyframes or expression, and the property's default value, an effect + parameter's or a Transform property's), Edit Value… (`prop.set {layer, prop}` without `value` + opens the value dialog), Separate Dimensions (Position: `prop.separateDimensions {layer, + value}`), Add / Remove Expression (`prop.setExpression {layer, prop}`) and Add Property to + Essential Graphics (`essential.addProperty`). ### History, puppet recording, plug-ins diff --git a/docs/parity.md b/docs/parity.md index 9e3fab58..2adb4490 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -166,12 +166,12 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | Automation | ≈ 99% | 0.1 | `.jsxbin` (AUT-2: concave / self-intersecting `onDraw` fills, real images in `drawImage`, image controls and icon buttons landed in M13.11; ScriptUI resource strings, `onDraw`/ScriptUIGraphics, live `onChanging`, `Socket`, Essential Graphics hooks — `addToMotionGraphicsTemplate(As)`, `canAddToMotionGraphicsTemplate`, `exportAsMotionGraphicsTemplate`, `motionGraphicsTemplateName`, controller count/names — and Watch Folder landed in M13.7; the core object model landed in M14.4; ScriptUI windows/dialogs/dockable panels with `scriptui.*` agent commands, File ▸ Scripts install + sample scripts, and the effect plug-in API (EFF-6, WebAssembly) landed in M13.1) | | Shapes | ≈ 80% | 1.5 | Lottie can't carry stroke taper/wave (stroke Taper and Wave, Dash 2/Gap 2/Dash 3/Gap 3 and radial-gradient Highlight Length/Angle landed in M13.5; pen tool for shape paths and vertex editing in M6.5) | | Compositions | ≈ 81% | 3.3 | Mocha-style planar tracks for templates; nested comp markers on the precomp layer bar (Preserve frame rate / resolution when nested and nested comps showing nothing past their end landed in M4.9–M4.10; Pre-compose keeping the comp's settings and New Comp from Selection's dialog in M3.12; CMP-7: Essential Graphics mirrored and linked properties landed in M13.11; Font and uniform Scale controls, Composition ▸ Open in Essential Graphics, Save Frame As ▸ Photoshop Layers / ProEXR and the VR Comp Editor landed in M13.7; the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) | -| Animation | 70% | 9.0 | puppet depth beyond pins, recording, rigging and follow-through (G2: an angle's revolutions scrub and take typing in Effect Controls, the Timeline and Properties, #93; puppet pin recording with Record Options landed in M13.1; pin selection, rotate/scale handles, nulls for pins and Follow-Through in M13.14–M13.16), Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) | +| Animation | 70% | 9.0 | puppet depth beyond pins, recording, rigging and follow-through (G2: the expression pick whip scrolls the Timeline when held at its top or bottom, #362; dropped on one of a property's values it picks that dimension (`position[0]`), #363; the inline expression field grows while typing and its bottom edge drags it taller or shorter, #371; an angle's revolutions scrub and take typing in Effect Controls, the Timeline and Properties, #93; puppet pin recording with Record Options landed in M13.1; pin selection, rotate/scale handles, nulls for pins and Follow-Through in M13.14–M13.16), Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) | | Text | ≈ 96% | 0.3 | no extruded strokes (G2: the font menus list every installed family with its own styles — before, only a project asking for a font read the system fonts — and `text.fonts` / MCP `list_fonts` list them for agents; M13.12: the Variable Font Axes animator re-spaces the text — advances follow the animated axes; M13.6: variable font axes in the character style — `layer.setText variations`, the Character panel's Variable Font Axes fields — shape with HVAR / gvar advances and draw at that design-space position; OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) | | Web | 98% | 0.1 | No shared-memory threads inside one engine instance (the decided design is one engine instance per worker; a threaded build needs nightly `build-std`) (M13.30: Render Queue, analyses and Content-Aware Fill render on each job worker's own WebGPU device in passes, particles and Advanced 3D read back under keys, layer buffers in the browser's disk cache; browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2; viewer frames in frame workers fed by project diffs, Roto Brush propagation in a worker, non-blocking `wait: true` jobs and a browser Media Browser (File System Access folders, browser storage) in M13.10; WEB-1 in M13.24: GPU effects and the GPU compositor in the frame workers on their own WebGPU devices with deferred readbacks (frames render in passes; Backend Auto per comp), the disk cache in the Origin Private File System (written by the workers with sync access handles, LRU under the settings' limit, served after a reload), the storage manager (Settings ▸ Disk ▸ Browser Storage, `storage.info` / `storage.persist` / `storage.clear`), and a WGSL constant Chrome rejected (it disabled every GPU kernel in Chrome) fixed) | | 3D | 88% | 5.5 | reflections of other layers (Advanced 3D surfaces reflect only the Environment light; Material Options ▸ Appears in Reflections is stored but has no visible effect yet, #261), multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6; Advanced 3D end to end on the GPU (motion blur, iris depth of field, compositing) in M13.11 | | Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (280 run on the GPU since M13.29, EFF-5: M13.28 added the remaining pixel effects and the geometry and audio generators (Lightning, Advanced Lightning, Beam, Lens Flare, Radio Waves, Vegas, Stroke, Scribble, Write-on, Paint Bucket, Eyedropper Fill, CC Glue Gun, CC Threads, Audio Spectrum / Waveform, Basic / Path Text); M13.29 added the particle effects (CC Particle World / Systems II, Particle Playground, CC Ball Action, CC Pixel Polly, CC Scatterize, Curl Noise) and removed the fallbacks of Shatter's wireframe views, Foam's extras, Timewarp's Matte Layer, strong Warp Fisheye / Twist bends and custom `.ocio` configs; M13.23 added the 3D Channel and Immersive Video families, Apply Color LUT, the OCIO effects, Color Profile Converter and the simulations' render passes (Card Wipe included); M13.22 added the CC light family, the CC transitions, Block Dissolve, Radial Shadow, CC Bender / Blobbylize / Cylinder / Sphere / Spotlight / Environment, 3D Glasses, Numbers, Timecode and the time effects joined the 166 of M13.13; Fractal and the analysis / tool effects still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) | -| Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (G2 on 9 October: the in-window menu bar works from the keyboard (Alt, arrows, Enter, Escape) and the app's shortcuts wait while a menu is open, #279; the Timeline's composition marker bin and numpad * add comp markers, #275; Settings ▸ Appearance ▸ UI Scale (75–200 %, beyond After Effects, which follows the system's scaling), #284; G2 on 7 October: Project items, files and effects dropped on the Composition viewer, Project items dropped in the Timeline between layers and at a time, #85, #88, #89; popup menus moved to fit the window take clicks, so Render Queue templates apply, #117; View ▸ New Viewer landed in M13.32; an automation id on every control of Composition and Solid Settings, Group / Ungroup Shapes in the Layer menu, Window ▸ Learn, the flowchart shortcuts and Reveal in Explorer landed in M3.10 and M3.13; the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | +| Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (G2 on 9 October: the viewer's resize cursors point the way each handle scales and turn with the layer, #393; Ctrl/Cmd-click on the Timeline's or the Composition panel's current-time display toggles Timecode / Frames, #370; Tab / Shift+Tab in a value being typed commits it and types in the next / previous value of the panel (Timeline, Effect Controls), #361; right-clicking a property's name in the Timeline opens its menu (Reset, Edit Value…, Separate Dimensions, Add / Remove Expression, Add Property to Essential Graphics), #407; the in-window menu bar works from the keyboard (Alt, arrows, Enter, Escape) and the app's shortcuts wait while a menu is open, #279; the Timeline's composition marker bin and numpad * add comp markers, #275; Settings ▸ Appearance ▸ UI Scale (75–200 %, beyond After Effects, which follows the system's scaling), #284; G2 on 7 October: Project items, files and effects dropped on the Composition viewer, Project items dropped in the Timeline between layers and at a time, #85, #88, #89; popup menus moved to fit the window take clicks, so Render Queue templates apply, #117; View ▸ New Viewer landed in M13.32; an automation id on every control of Composition and Solid Settings, Group / Ungroup Shapes in the Layer menu, Window ▸ Learn, the flowchart shortcuts and Reveal in Explorer landed in M3.10 and M3.13; the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | | Project | ≈ 68% | 5.5 | OCIO displays beyond the built-in tone map; relative footage paths and relinking when a project moves (unsaved-changes prompts, a modified mark that follows undo and project files that name their version and always reopen landed in M3.9 and M3.14; auto-save and crash recovery in M3.7; Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3; image sequence import options (Sequence, Force Alphabetical Order, frame rate, picked ranges, folders, the browser), missing-frame placeholders and Interpret Footage Start Timecode landed: #297) | | Masks & roto | 74% | 5.0 | (audit estimates; Roto Brush 2.0 / 3.0's trained model, MobileSAM in the swappable `segment` module, has since landed in M13.35; variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) | | Preview | 77% | 2.8 | GPU effects for the audio-driven effects and the stepped particle systems; content-keyed RAM preview frames (an edit drops every comp's), audio scrubbing, more snapshot slots (G2 on 7 October: Audio, Lock and Shy switches keep the cached frames and playback with audio waits for frames instead of skipping them, #103; running out of video memory renders the frame on the CPU and keeps fewer GPU frames instead of crashing, #106; RAM preview keys with the render options, least-recently-shown eviction, Auto resolution while playing, Cache Before Playback that fits, an achieved-fps readout, Cache Frames When Idle and purges that clear what they name landed in M4.5–M4.8; 3D channel, VR, OCIO, Card Wipe and the simulations' render passes run on the GPU since M13.23; Advanced 3D layers with blend modes / track mattes / Preserve Transparency and environment backgrounds composite on the GPU since M13.22; pooled GPU textures and fused quantisation since M13.13: Lower Third GPU warm 21 → 4 ms/frame, and Auto now picks the CPU or the GPU per comp from measured frame times; Advanced 3D runs — raster, motion blur, iris depth of field, compositing — and wireframes run on the GPU since M13.11: CPU warm 3394 → GPU warm 290 ms/frame at 1920×1080 (11.7×; Half 968 → 85 ms), GPU ≠ CPU on 0.005 % of pixels, on an M4 Pro under load; Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) |