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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
This commit is contained in:
Claude committed 2026-10-10 19:16:47 +00:00
1 parent 76c9abad50
commit e152dfd1d1
4 files changed
+96 -21

No files matched your search

+31
View File
@@ -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));
+1 -21
View File
@@ -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<f64> {
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;
}
+20
View File
@@ -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<f64> {
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()
}
+44
View File
@@ -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:?}");
}