Properties/Timeline/Effect Controls: a typed component edits only that component of the other selected layers (#1434)

With several layers selected, typing a value into one dimension (say Z
Position) sent the edited layer's whole value with selected: true, and
prop.set gave every selected layer that same value: they all took the first
layer's X and Y and stacked on top of it. Scrubbing was fine (it sends an
offset).

prop.set takes dims: with selected and a typed value, the other selected
layers get only those components and keep the rest of their own value (Position
with Separate Dimensions included). The three panels send the components the
edit changed (both for linked Scale). Without dims, agents still set the whole
value on every selected layer.

Fixes #1434

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N9kwMcw2pYQ5fSjKa3ZPnh
This commit is contained in:
DWAA1660
2026-10-14 14:39:26 -06:00
co-authored by Claude Opus 5.5
parent 05955c08b4
commit 642eed0802
6 changed files with 124 additions and 2 deletions
+22 -2
View File
@@ -241,7 +241,9 @@ pub(crate) fn proportional_share(index: usize, count: usize) -> f64 {
}
/// `prop.set`; with `selected`, also on the other selected layers: the same value, or with
/// `offset` (a scrub) the same change from each layer's own value. With `offset` and
/// `offset` (a scrub) the same change from each layer's own value. A typed value with `dims`
/// (the components edited, e.g. `[2]` for Z) sets only those components on the other layers,
/// which keep the rest of their own value (#1434). With `offset` and
/// `proportional` (Proportional Scrubbing) each layer takes [`proportional_share`] of the change
/// by its place in the selection order, the scrubbed layer too. One undo step (with `merge`,
/// one that a continuing scrub keeps folding into).
@@ -261,6 +263,23 @@ fn set_selected(s: &mut Session, p: &Value) -> Result<Value> {
.then(|| s.state.selected_layers.iter().copied().filter(|o| *o == lid || twins.iter().any(|(t, _)| t == o)).collect());
let share = |l: LayerId| order.as_ref().map_or(1.0, |o| o.iter().position(|x| *x == l).map_or(1.0, |i| proportional_share(i, o.len())));
let scalar = p.get("value").is_some_and(Value::is_number);
// Typed into some components only: the others stay each layer's own.
let dims: Option<Vec<usize>> = match (&delta, p.get("dims").and_then(Value::as_array), &new) {
(None, Some(a), Some(_)) if !scalar => Some(a.iter().filter_map(Value::as_u64).filter_map(|d| usize::try_from(d).ok()).collect()),
_ => None,
};
let typed = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid| {
if let (Some(dims), Some(n)) = (&dims, &new)
&& let Some(mut c) = current_components(s, cid, olid, ouid)
{
for &d in dims {
if let (Some(slot), Some(v)) = (c.get_mut(d), n.get(d)) {
*slot = *v;
}
}
q["value"] = json!(c);
}
};
// `p` aimed at (layer, prop) with its value moved by `k` × the change.
let shifted = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid, k: f64| {
if let Some(d) = &delta
@@ -291,6 +310,7 @@ fn set_selected(s: &mut Session, p: &Value) -> Result<Value> {
}
let mut q = twin_params(p, olid, ouid);
shifted(s, &mut q, olid, ouid, k);
typed(s, &mut q, olid, ouid);
// (a layer that can't take it, e.g. a clamped or static property, keeps its value)
let _ = set(s, &q);
}
@@ -1229,7 +1249,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Property Value",
[],
None,
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all)}",
"{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change), proportional? (with offset: Proportional Scrubbing, each layer by its share in selection order, the first none, the last all), dims? (with selected, a typed value: the components edited, e.g. [2] for Z; the other layers keep the rest of their own value)}",
has_layers,
set_selected
),
+42
View File
@@ -1193,3 +1193,45 @@ fn relative_trim_moves_every_selected_layer_by_the_same_delta() {
s.execute("layer.timing", json!({"layers": [a], "inDelta": 0.1})).unwrap();
assert!(near(io(&s, a).0, -0.9), "{:?}", io(&s, a));
}
/// #1434: with several 3D layers selected, typing a value into one component (Z Position) sets
/// that component on each of them; their other components stay their own. Before, every
/// selected layer took the edited layer's whole value and they piled up on top of it.
#[test]
fn a_typed_component_applies_only_that_component_to_the_selected_layers() {
let (mut s, a) = setup();
let b = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
let c = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
for (l, xy) in [(a, [10.0, 20.0]), (b, [110.0, 120.0]), (c, [210.0, 220.0])] {
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [xy[0], xy[1], 5.0]})).unwrap();
}
s.execute("layer.select", json!({"layers": [a, b, c]})).unwrap();
let pos = |s: &Session, l: u64| prop(s, l, "transform/position").value.components();
let uid = prop(&s, a, "transform/position").uid;
// The panels send the edited layer's value with the typed component, and which one it was.
let undo = s.history.undo.len();
s.execute_checked("prop.set", json!({"layer": a, "prop": uid, "value": [10.0, 20.0, -300.0], "selected": true, "offset": false, "dims": [2]})).unwrap();
assert_eq!(pos(&s, a), vec![10.0, 20.0, -300.0]);
assert_eq!(pos(&s, b), vec![110.0, 120.0, -300.0], "B keeps its X and Y");
assert_eq!(pos(&s, c), vec![210.0, 220.0, -300.0], "C keeps its X and Y");
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(pos(&s, b), vec![110.0, 120.0, 5.0]);
// Linked Scale: both edited components go to every layer, Z stays each layer's own.
s.execute("prop.set", json!({"layer": c, "path": "transform/scale", "value": [50.0, 50.0, 30.0]})).unwrap();
let scale_uid = prop(&s, a, "transform/scale").uid;
s.execute("prop.set", json!({"layer": a, "prop": scale_uid, "value": [200.0, 200.0, 100.0], "selected": true, "dims": [0, 1]})).unwrap();
assert_eq!(prop(&s, c, "transform/scale").value.components(), vec![200.0, 200.0, 30.0]);
// Position with Separate Dimensions on one of the other layers.
s.execute("prop.separateDimensions", json!({"layer": b, "value": true})).unwrap();
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [10.0, 20.0, 40.0], "selected": true, "dims": [2]})).unwrap();
let axis = |m: &str| prop(&s, b, &format!("transform/{m}")).value.as_f64();
assert_eq!((axis("positionX"), axis("positionY"), axis("positionZ")), (110.0, 120.0, 40.0));
// Without `dims` (agents setting a whole value) every selected layer takes the value as is.
s.execute("prop.set", json!({"layer": a, "prop": uid, "value": [1.0, 2.0, 3.0], "selected": true})).unwrap();
assert_eq!(pos(&s, c), vec![1.0, 2.0, 3.0]);
}
@@ -377,6 +377,11 @@ fn prop_row(
let mut nc = c.clone();
nc[d] = nv;
set(actions, json!(nc));
// Typed into this component only: other selected layers keep the rest of
// their own value (#1434).
if let Some((_, a)) = actions.last_mut() {
a["dims"] = json!([d]);
}
}
x = vr.max.x + 8.0;
}
+5
View File
@@ -306,6 +306,11 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
}
}
set(actions, json!(nc));
// Typed into these components only: other selected layers keep the rest of
// their own value (#1434).
if let Some((_, a)) = actions.last_mut() {
a["dims"] = json!((from..from + shown).collect::<Vec<_>>());
}
}
x = vr.max.x + 10.0;
}
+5
View File
@@ -4064,6 +4064,11 @@ fn value_editor(
}
}
set(actions, json!(nc));
// Typed into these components only: other selected layers keep the rest of
// their own value (#1434).
if let Some((_, a)) = actions.last_mut() {
a["dims"] = json!((from..from + shown).collect::<Vec<_>>());
}
}
x = r.max.x + if d + 1 < n { 2.0 } else { 0.0 };
if d + 1 < n {
+45
View File
@@ -539,3 +539,48 @@ fn arrow_keys_move_the_caret_in_rename_fields() {
let layer_name = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().name.clone();
assert_eq!(layer_name, "Pla-te");
}
/// #1434: with several 3D layers selected, typing a Z Position sets Z on each of them; their X
/// and Y stay their own. Every selected layer used to take the typed layer's whole Position, so
/// they stacked on top of it and seemed to disappear.
#[test]
fn typing_one_component_with_several_layers_selected_keeps_their_other_components() {
let (mut h, _) = harness();
let (a, b, transform) = {
let s = &mut h.state_mut().session;
let mut ids = vec![];
for (name, xy) in [("A", [40.0, 50.0]), ("B", [200.0, 120.0])] {
let l = s.execute("layer.newSolid", json!({"name": name, "color": "#406080", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [xy[0], xy[1], 0.0]})).unwrap();
ids.push(l);
}
s.execute("layer.select", json!({"layers": ids})).unwrap();
let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(ids[0])).unwrap().props.sub("transform").unwrap().uid;
(ids[0], ids[1], tr)
};
h.state_mut().ui.timeline.open_layers.insert(a);
h.state_mut().ui.timeline.open_groups.insert(transform);
h.run_steps(3);
let pos = |h: &Harness<'_, EffectcraftApp>, l: u64| {
h.state()
.session
.active_comp()
.unwrap()
.layer(effectcraft_engine::project::LayerId(l))
.unwrap()
.props
.prop("transform/position")
.unwrap()
.value
.components()
};
let position =
h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(a)).unwrap().props.prop("transform/position").unwrap().uid;
let p = center(&h, &format!("timeline.prop.{position}.value.2"));
click(&mut h, p, 1);
type_text(&mut h, "-300");
key(&mut h, Key::Enter);
assert_eq!(pos(&h, a), [40.0, 50.0, -300.0]);
assert_eq!(pos(&h, b), [200.0, 120.0, -300.0], "B keeps its own X and Y");
}