From e152dfd1d187de780fd73705df5d71b7506308f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 19:16:47 +0000 Subject: [PATCH] Essential Graphics: Scale keeps its Constrain Proportions link, on by default (#338) A Scale property added to Essential Graphics fell into the generic Point control: two independent fields with no link, so dragging one value distorted the layer, unlike After Effects, whose Scale control keeps the chain link. It now draws the link (on by default) with one field per axis; linked, an edit scales the others in proportion (Alt edits one), using the Timeline's constrained() (moved to widgets). The link state is the property's own, shared with the Timeline's chain link. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/panels/essential.rs | 31 ++++++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 22 +------------ crates/ui-egui/src/widgets.rs | 20 ++++++++++++ crates/ui-egui/tests/ui_panels.rs | 44 ++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/src/panels/essential.rs b/crates/ui-egui/src/panels/essential.rs index 26d458d4..2496b238 100644 --- a/crates/ui-egui/src/panels/essential.rs +++ b/crates/ui-egui/src/panels/essential.rs @@ -375,6 +375,37 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt }); r.response.rect } + // Scale keeps its Constrain Proportions link, on by default, as in After Effects (#338). + // The link is the property's own, shared with the Timeline's chain link. + ControlType::Point if p.match_id == "scale" && matches!(p.ui, ParamUi::Percent) => { + let comps = v.components(); + let linked = !app.ui.timeline.unlinked.contains(&p.uid); + let (lr, link) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::click()); + let t = app.tokens; + crate::icons::paint(ui.painter(), lr, crate::icons::Icon::Link, if linked { t.accent } else { t.text_faint }); + let link = link.on_hover_text(tr("Constrain Proportions")); + app.auto.add(&format!("{auto}.link"), lr, "Constrain Proportions"); + if link.clicked() && !app.ui.timeline.unlinked.remove(&p.uid) { + app.ui.timeline.unlinked.insert(p.uid); + } + let mut rect = lr; + for (d, &old) in comps.iter().enumerate() { + let mut x = old; + let r = ui.add(egui::DragValue::new(&mut x).speed(0.5).suffix(if d + 1 == comps.len() { "%" } else { "" }).max_decimals(1)); + app.auto.add(&format!("{auto}.{d}"), r.rect, &p.name); + rect = rect.union(r.rect); + if r.changed() { + // Alt edits one value of a linked pair, as in the Timeline. + let next = if linked && !ui.input(|i| i.modifiers.alt) { + widgets::constrained(&comps, d, x) + } else { + comps.iter().enumerate().map(|(e, &c)| if e == d { x } else { c }).collect() + }; + out = Some(json!(next)); + } + } + rect + } ControlType::Point => { let mut a = v.as_vec2(); let r1 = ui.add(egui::DragValue::new(&mut a[0]).speed(1.0).max_decimals(1)); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 7df3439b..15149560 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3440,26 +3440,6 @@ fn fmt_num(v: f64, d: usize) -> String { format!("{v:.d$}") } -/// Linked values after component `d` of `c` became `v`: the others scale by the same ratio -/// (Constrain Proportions); from 0 the others that are 0 too follow the value. -fn constrained(c: &[f64], d: usize, v: f64) -> Vec { - let Some(&old) = c.get(d) else { return c.to_vec() }; - c.iter() - .enumerate() - .map(|(e, &x)| { - if e == d { - v - } else if old.abs() > 1e-9 { - x * v / old - } else if x.abs() <= 1e-9 { - v - } else { - x - } - }) - .collect() -} - /// 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( @@ -3535,7 +3515,7 @@ fn value_editor( // Alt edits one value of a linked pair. let shown = if linked && !ui.input(|i| i.modifiers.alt) { n.min(c.len()) } else { 1 }; let from = if shown == 1 { d } else { 0 }; - for (e, v) in constrained(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() { + for (e, v) in widgets::constrained(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() { if let Some(slot) = nc.get_mut(from + e) { *slot = v; } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 8f12e52b..fdb8287b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -559,3 +559,23 @@ pub fn popup_is_open(ui: &Ui, id: egui::Id) -> bool { pub fn open_popup(ui: &Ui, id: egui::Id) { ui.data_mut(|d| d.insert_temp(id.with("open"), true)); } + +/// Linked values after component `d` of `c` became `v`: the others scale by the same ratio +/// (Constrain Proportions); from 0 the others that are 0 too follow the value. +pub(crate) fn constrained(c: &[f64], d: usize, v: f64) -> Vec { + let Some(&old) = c.get(d) else { return c.to_vec() }; + c.iter() + .enumerate() + .map(|(e, &x)| { + if e == d { + v + } else if old.abs() > 1e-9 { + x * v / old + } else if x.abs() <= 1e-9 { + v + } else { + x + } + }) + .collect() +} diff --git a/crates/ui-egui/tests/ui_panels.rs b/crates/ui-egui/tests/ui_panels.rs index d58c2146..0ecefa48 100644 --- a/crates/ui-egui/tests/ui_panels.rs +++ b/crates/ui-egui/tests/ui_panels.rs @@ -271,3 +271,47 @@ fn panels_snapshot() { img.save(format!("{dir}/{panel}{scope}.png")).unwrap(); } } + +/// Drag the automation element `id` horizontally by `dx` points. +fn drag_x(h: &mut Harness<'_, EffectcraftApp>, id: &str, dx: f32) { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone(); + let from: Pos2 = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for k in 1..=10 { + h.input_mut().events.push(Event::PointerMoved(from + egui::vec2(dx * k as f32 / 10.0, 0.0))); + h.step(); + } + let to = from + egui::vec2(dx, 0.0); + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); +} + +/// #338: an Essential Graphics Scale control keeps After Effects' Constrain Proportions link, on +/// by default: dragging X scales Y with it; unlinked, X moves alone. +#[test] +fn essential_graphics_scale_keeps_constrain_proportions() { + let mut h = harness(); + let layer = h.state().session.active_comp().unwrap().layers[0].id.0; + h.state_mut().session.execute("prop.set", json!({"layer": layer, "path": "transform/scale", "value": [100.0, 50.0]})).unwrap(); + let c = + h.state_mut().session.execute("essential.addProperty", json!({"layer": layer, "path": "transform/scale"})).unwrap()["controls"][0].as_u64().unwrap(); + open(&mut h, "essentialGraphics"); + let value = format!("essential.control.{c}.value"); + let scale = |h: &Harness<'_, EffectcraftApp>| { + let comp = h.state().session.active_comp().unwrap(); + let l = comp.layers.iter().find(|l| l.id.0 == layer).unwrap(); + l.props.prop("transform/scale").unwrap().value.components() + }; + assert!(h.state().auto.find(&format!("{value}.link")).is_some(), "the chain link is drawn"); + drag_x(&mut h, &format!("{value}.0"), 40.0); + let linked = scale(&h); + assert!(linked[0] > 100.0, "{linked:?}"); + assert!((linked[1] / linked[0] - 0.5).abs() < 1e-6, "Y follows X in proportion: {linked:?}"); + click(&mut h, &format!("{value}.link")); + drag_x(&mut h, &format!("{value}.0"), 40.0); + let unlinked = scale(&h); + assert!(unlinked[0] > linked[0] && unlinked[1] == linked[1], "unlinked, X moves alone: {unlinked:?}"); +}