diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index e565034e..2649a38e 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -277,16 +277,18 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, let c = value.components(); let n = if pr.shown_dims > 0 && !(layer.is_3d() && c.len() == 3) { pr.shown_dims as usize } else { c.len() }; let mut x = vx; - let link_id = egui::Id::new(("props-scale-link", lid)); - let linked = is_scale && ui.data(|d| d.get_temp::(link_id)).unwrap_or(true); - if is_scale { + // Scale: the chain link (Constrain Proportions, on by default), sharing the + // Timeline's per-property state so both panels agree (#956). + let linkable = pr.match_id == "scale"; + let linked = linkable && !app.ui.timeline.unlinked.contains(&uid); + if linkable { // Constrain Proportions toggle, left of the values. let lr = Rect::from_center_size(pos2(x - 12.0, cy), vec2(12.0, 12.0)); - let resp = ui.interact(lr, link_id.with("btn"), Sense::click()); + let resp = ui.interact(lr, egui::Id::new(("props-link", uid)), Sense::click()).on_hover_text(tr("Constrain Proportions")); icons::paint(p, lr, Icon::Link, if linked { t.accent } else { t.text_faint }); app.auto.add(&format!("{base}.constrain"), lr, "Constrain Proportions"); - if resp.clicked() { - ui.data_mut(|d| d.insert_temp(link_id, !linked)); + if resp.clicked() && !app.ui.timeline.unlinked.remove(&uid) { + app.ui.timeline.unlinked.insert(uid); } } for d in 0..n.min(c.len()) { @@ -295,13 +297,14 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, app.auto.add(&format!("{base}.value.{d}"), vr, &pr.name); if let Some(nv) = nv { let mut nc = c.clone(); - if linked && c[d].abs() > 1e-9 { - let k = nv / c[d]; - for v in nc.iter_mut().take(n) { - *v *= k; + // Alt edits one value of a linked pair, as in the Timeline. + let shown = if linked && !ui.input(|i| i.modifiers.alt && !widgets::proportional_scrub(i.modifiers)) { n.min(c.len()) } else { 1 }; + let from = if shown == 1 { d } else { 0 }; + 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; } } - nc[d] = nv; set(actions, json!(nc)); } x = vr.max.x + 10.0; diff --git a/crates/ui-egui/tests/ui_panels.rs b/crates/ui-egui/tests/ui_panels.rs index e3fee868..af05cb40 100644 --- a/crates/ui-egui/tests/ui_panels.rs +++ b/crates/ui-egui/tests/ui_panels.rs @@ -466,6 +466,44 @@ fn essential_graphics_scale_keeps_constrain_proportions() { assert!(unlinked[0] > linked[0] && unlinked[1] == linked[1], "unlinked, X moves alone: {unlinked:?}"); } +/// #956: the Properties panel's Scale chain link shares the Timeline's Constrain Proportions +/// state: unlinking in Properties unlinks the Timeline row too, and X/Y scale together only +/// while linked. +#[test] +fn properties_scale_link_shares_the_timeline_state() { + let mut h = harness(); + let layer = h.state().session.active_comp().unwrap().layers[0].id.0; + h.state_mut().session.execute("layer.select", json!({"layers": [layer]})).unwrap(); + h.state_mut().session.execute("prop.set", json!({"layer": layer, "path": "transform/scale", "value": [100.0, 50.0]})).unwrap(); + open(&mut h, "properties"); + let uid = { + 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().uid + }; + let link = format!("properties.prop.{uid}.constrain"); + assert!(h.state().auto.find(&link).is_some(), "the chain link is drawn"); + 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() + }; + // Linked: dragging X scales Y with it. + drag_x(&mut h, &format!("properties.prop.{uid}.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:?}"); + // Unlinking in Properties writes the shared state, so the Timeline row unlinks too. + click(&mut h, &link); + assert!(h.state().ui.timeline.unlinked.contains(&uid), "unlinking writes the shared state"); + drag_x(&mut h, &format!("properties.prop.{uid}.value.0"), 40.0); + let unlinked = scale(&h); + assert!(unlinked[0] > linked[0] && unlinked[1] == linked[1], "unlinked, X moves alone: {unlinked:?}"); + // Re-linking clears it again. + click(&mut h, &link); + assert!(!h.state().ui.timeline.unlinked.contains(&uid), "re-linking clears the shared state"); +} + /// #757: Effects & Presets lists the built-in Transitions presets under * Animation Presets; /// double-clicking one applies it to the selected layer (effect plus keyframes). #[test]