From b97cc92a61e0f890417fdd22de4bb8354047afc8 Mon Sep 17 00:00:00 2001 From: ashokram-si <347905169+ashokram-si@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:35:31 +0530 Subject: [PATCH] feat(ui): Rectangle and Ellipse Size get the Constrain Proportions chain (#2489) Resizing a rectangle or ellipse while keeping its proportions (a circle that stays a circle, a 16:9 card that stays 16:9) needed W and H typed or keyed separately. The Timeline's Size row of a shape's Rectangle Path and Ellipse Path now has the chain link that Scale and Mask Feather have. It starts off; turned on, typing or scrubbing W or H keeps the ratio (Alt still edits one value). Scale and Mask Feather keep their linked-by-default state in `unlinked`; Size, which starts unlinked, records a turned-on link in a new `linked` set, so neither default changes the other's saved state. --- ROADMAP.md | 3 ++ crates/ui-egui/src/panels/timeline.rs | 10 ++-- crates/ui-egui/src/state.rs | 20 ++++++++ crates/ui-egui/tests/it/ui_timeline_shapes.rs | 47 ++++++++++++++++++- 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f4c99d61f..1ce9d9bb2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -117,6 +117,9 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). ## Progress log +- 2026-10-28: A shape layer's Rectangle and Ellipse Size get the Constrain Proportions chain + that Scale has, so a circle stays a circle while its size is typed, scrubbed or keyed; it + starts off, so existing shapes behave as before (#2489). - 2026-10-16: Convert To Bezier Path turns a shape layer's Rectangle, Ellipse or Polystar path into an editable Path 1 with the same geometry, as After Effects does; it used to apply to mask RotoBezier only, so a parametric shape path could not be converted at all diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ea18c7f1e..f4cd4b991 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -4401,15 +4401,17 @@ fn value_editor( let feather = prop.match_id == "feather" && layer.props.parent_of(uid).is_some_and(|g| matches!(g.kind, GroupKind::Mask { .. })); let range = if feather { (0.0, 1e9) } else { (-1e9, 1e9) }; // Scale and Mask Feather: the chain link (Constrain Proportions, on by default). - let linkable = feather || (pct && prop.match_id == "scale"); - let linked = linkable && !app.ui.timeline.unlinked.contains(&uid); + // Rectangle and Ellipse Path Size have it too, off by default (#2489). + let size = prop.match_id == "size" && layer.props.parent_of(uid).is_some_and(|g| matches!(g.match_id.as_str(), "rect" | "ellipse")); + let linkable = feather || size || (pct && prop.match_id == "scale"); + let linked = linkable && app.ui.timeline.is_linked(uid, !size); if linkable { let lr = Rect::from_center_size(pos2(x + 7.0, at.y), vec2(14.0, 14.0)); let resp = ui.interact(lr, egui::Id::unique(("tl-link", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Constrain Proportions")); icons::paint(p, lr, Icon::Link, if linked { t.accent } else { t.text_faint }); app.auto.add(&format!("timeline.prop.{uid}.link"), lr, "Constrain Proportions"); - if resp.clicked() && !app.ui.timeline.unlinked.remove(&uid) { - app.ui.timeline.unlinked.insert(uid); + if resp.clicked() { + app.ui.timeline.toggle_link(uid, !size); } x += 18.0; } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 3a96d3c50..c1320054a 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -307,6 +307,21 @@ impl Default for ViewerState { } impl TimelineState { + /// Whether property `uid`'s chain link (Constrain Proportions) is on. One that starts linked + /// (Scale, Mask Feather) records a click in `unlinked`; one that starts unlinked (shape Size) + /// records it in `linked`. + pub fn is_linked(&self, uid: u64, linked_by_default: bool) -> bool { + if linked_by_default { !self.unlinked.contains(&uid) } else { self.linked.contains(&uid) } + } + + /// Turn property `uid`'s chain link on or off (see [`Self::is_linked`]). + pub fn toggle_link(&mut self, uid: u64, linked_by_default: bool) { + let set = if linked_by_default { &mut self.unlinked } else { &mut self.linked }; + if !set.remove(&uid) { + set.insert(uid); + } + } + /// Show `kinds` on `layers` (twirled open; empty: twirled closed) and remember it as the last /// reveal. Other layers keep theirs. pub fn apply_reveal(&mut self, layers: &[u64], kinds: Vec) { @@ -414,6 +429,10 @@ pub struct TimelineState { /// was turned off. #[serde(default)] pub unlinked: BTreeSet, + /// Shape Rectangle and Ellipse Path Size properties whose chain link (off by default) was + /// turned on (#2489). + #[serde(default)] + pub linked: BTreeSet, /// Visible optional columns (column header right-click ▸ Columns): `av`, `keys`, `label`, /// `num`, `comment`, `switches`, `parent`, `in`, `out`, `duration`, `stretch`. The name /// column is always shown; Modes follows `show_modes` (F4). @@ -466,6 +485,7 @@ impl Default for TimelineState { expr_closed: BTreeSet::new(), expr_lines: BTreeMap::new(), unlinked: BTreeSet::new(), + linked: BTreeSet::new(), columns: default_tl_columns(), source_name: false, scroll_to: None, diff --git a/crates/ui-egui/tests/it/ui_timeline_shapes.rs b/crates/ui-egui/tests/it/ui_timeline_shapes.rs index bc42b4461..76830aa88 100644 --- a/crates/ui-egui/tests/it/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/it/ui_timeline_shapes.rs @@ -1,7 +1,8 @@ //! Timeline rows for shape layers, masks and keyframing: the Contents "Add:" menu and the Classic //! 3D "Change Renderer…" row (#206), Alt+Shift+P revealing the key it adds and clicks below the -//! layers deselecting them (#205), Mask Feather's linked values (#203), and copying shape items -//! between shape layers (#227), and Ctrl+V of SVG markup making a shape layer (#738). +//! layers deselecting them (#205), Mask Feather's linked values (#203) and shape Size's chain link +//! (#2489), copying shape items between shape layers (#227), and Ctrl+V of SVG markup making a shape +//! layer (#738). use effectcraft_engine::Session; use effectcraft_engine::project::LayerId; @@ -172,6 +173,48 @@ fn mask_feather_values_are_linked_and_not_negative() { assert_eq!(feather(&h).1, vec![0.0, 7.0], "unlinked"); } +/// #2489: Rectangle and Ellipse Path Size get the Constrain Proportions chain, off by default: +/// unlinked, W moves alone; linked, H follows W in proportion. Scale on the same layer still starts +/// linked. +#[test] +fn shape_size_chain_starts_unlinked_and_keeps_the_ratio_when_linked() { + let mut s = session(); + let l = s.execute("layer.newShape", json!({"kind": "rect", "size": [100, 50]})).unwrap()["layer"].as_u64().unwrap(); + let o = s.execute("layer.newShape", json!({"kind": "ellipse", "size": [60, 30]})).unwrap()["layer"].as_u64().unwrap(); + let uid = |s: &Session, l: u64, path: &str| s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop(path).unwrap().uid; + let (size, oval, scale) = + (uid(&s, l, "contents/group/contents/rect/size"), uid(&s, o, "contents/group/contents/ellipse/size"), uid(&s, l, "transform/scale")); + let mut h = harness(s); + for (layer, props) in [(l, vec![size, scale]), (o, vec![oval])] { + h.state_mut().ui.timeline.open_layers.insert(layer); + h.state_mut().ui.timeline.layer_reveal.insert(layer, vec!["props".into()]); + h.state_mut().ui.timeline.reveal_props.extend(props); + } + h.run_steps(2); + let value = |h: &Harness<'_, EffectcraftApp>, l: u64, uid: u64| { + h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop(&format!("@{uid}")).unwrap().value.components() + }; + let type_value = |h: &mut Harness<'_, EffectcraftApp>, uid: u64, d: usize, v: &str| { + click(h, &format!("timeline.prop.{uid}.value.{d}")); + h.input_mut().events.push(Event::Text(v.into())); + h.step(); + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + }; + type_value(&mut h, size, 0, "80"); + assert_eq!(value(&h, l, size), vec![80.0, 50.0], "a new rectangle's Size starts unlinked"); + type_value(&mut h, oval, 1, "40"); + assert_eq!(value(&h, o, oval), vec![60.0, 40.0], "a new ellipse's Size starts unlinked"); + click(&mut h, &format!("timeline.prop.{size}.link")); + type_value(&mut h, size, 0, "160"); + assert_eq!(value(&h, l, size), vec![160.0, 100.0], "linked, H follows W in proportion"); + click(&mut h, &format!("timeline.prop.{size}.link")); + type_value(&mut h, size, 0, "40"); + assert_eq!(value(&h, l, size), vec![40.0, 100.0], "unlinked again, H stays"); + type_value(&mut h, scale, 0, "50"); + assert_eq!(value(&h, l, scale)[..2], [50.0, 50.0], "Scale still starts linked"); +} + /// Two shape layers, "Rect" (Rectangle 1) and "Oval" (Ellipse 1, selected): (harness with Rect's /// Contents open, Rect, Oval, Rectangle 1's uid). fn two_shape_layers() -> (Harness<'static, EffectcraftApp>, u64, u64, u64) {