diff --git a/crates/engine/src/cmd/prop.rs b/crates/engine/src/cmd/prop.rs index ae37ea1b7..028fdb0dd 100644 --- a/crates/engine/src/cmd/prop.rs +++ b/crates/engine/src/cmd/prop.rs @@ -371,24 +371,30 @@ fn set_selected(s: &mut Session, p: &Value) -> Result { }; let follow: Vec = p.get("follow").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).filter_map(|d| usize::try_from(d).ok()).collect(); - // A typed `*2` or `/2` (#3195): each layer's own value times it, not the edited layer's result. + // A typed `*2`, `/2` (`factor`) or `+10` (`addend`) (#3195): each layer's own value times it + // or plus it, not the edited layer's result (which its range may have clamped). let factor = p.get("factor").and_then(Value::as_f64).filter(|k| k.is_finite() && delta.is_none()); + let addend = p.get("addend").and_then(Value::as_f64).filter(|n| n.is_finite() && delta.is_none()); + let own_step = |v: f64| -> Option { + let r = match (factor, addend) { + (Some(k), _) => v * k, + (None, Some(n)) => v + n, + (None, None) => return None, + }; + r.is_finite().then_some(r) + }; // Each layer's own value where `set` writes it (a `time` other than the CTI keys there). let typed = |s: &Session, q: &mut Value, olid: LayerId, ouid: Uid| { let at = snapped_key_time(s, cid, olid, q, "prop.set").ok().flatten(); - if let (true, Some(k)) = (scalar, factor) - && let Some(own) = components_at(s, cid, olid, ouid, at).and_then(|c| c.first().copied()) - && (own * k).is_finite() - { - q["value"] = json!(own * k); + if scalar && let Some(v) = components_at(s, cid, olid, ouid, at).and_then(|c| c.first().copied()).and_then(own_step) { + q["value"] = json!(v); } if let (Some(dims), Some(n)) = (&dims, &new) && let Some(mut c) = components_at(s, cid, olid, ouid, at) { // The typed components' new values on this layer. - let typed_value = |c: &[f64], d: usize| match factor { - Some(k) => c.get(d).map(|v| v * k).filter(|v| v.is_finite()), - None => n.get(d).copied(), + let typed_value = |c: &[f64], d: usize| { + if factor.is_some() || addend.is_some() { c.get(d).copied().and_then(own_step) } else { n.get(d).copied() } }; let change = dims.first().and_then(|&d| Some(typed_value(&c, d)? - c.get(d)?)).unwrap_or(0.0); let values: Vec> = dims.iter().map(|&d| typed_value(&c, d)).collect(); @@ -1459,7 +1465,7 @@ pub fn specs() -> Vec { "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), dims? (with selected, a typed value: the components edited, e.g. [2] for Z; the other layers keep the rest of their own value), follow? (with dims: components that move on each other layer by the change its first typed component takes), factor? (with selected, a typed `*` or `/`: the other layers take their own value times it, in the dims components of a vector)}", + "{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), follow? (with dims: components that move on each other layer by the change its first typed component takes), factor? (with selected, a typed `*` or `/`: the other layers take their own value times it, in the dims components of a vector), addend? (the same for a typed `+`: their own value plus it)}", has_layers, set_selected ), diff --git a/crates/engine/src/tests/timeline.rs b/crates/engine/src/tests/timeline.rs index 43eb922e2..f291de627 100644 --- a/crates/engine/src/tests/timeline.rs +++ b/crates/engine/src/tests/timeline.rs @@ -1552,6 +1552,17 @@ fn a_typed_factor_scales_each_selected_layers_own_value() { s.execute_checked("prop.set", json!({"layer": a, "prop": position, "value": [5.0, 20.0], "selected": true, "dims": [0], "factor": 0.5})).unwrap(); assert_eq!(prop(&s, a, "transform/position").value.components()[..2], [5.0, 20.0]); assert_eq!(prop(&s, b, "transform/position").value.components()[..2], [50.0, 50.0], "B halves its own X, keeps its Y"); + // A typed `+10` (`addend`): each layer's own value plus 10, within its own range, even where + // the edited layer's range cut its change short (95 + 10 stops at 100). + s.execute("prop.set", json!({"layer": a, "path": "transform/opacity", "value": 95.0})).unwrap(); + s.execute("prop.set", json!({"layer": b, "path": "transform/opacity", "value": 30.0})).unwrap(); + s.execute_checked("prop.set", json!({"layer": a, "prop": opacity, "value": 100.0, "selected": true, "addend": 10.0})).unwrap(); + assert_eq!(prop(&s, b, "transform/opacity").value.as_f64(), 40.0, "B adds 10, not the 5 A moved"); + s.execute("prop.set", json!({"layer": b, "path": "transform/opacity", "value": 95.0})).unwrap(); + s.execute_checked("prop.set", json!({"layer": a, "prop": opacity, "value": 100.0, "selected": true, "addend": 10.0})).unwrap(); + assert_eq!(prop(&s, b, "transform/opacity").value.as_f64(), 100.0, "B's own range still applies"); + s.execute_checked("prop.set", json!({"layer": a, "prop": position, "value": [15.0, 20.0], "selected": true, "dims": [0], "addend": 10.0})).unwrap(); + assert_eq!(prop(&s, b, "transform/position").value.components()[..2], [60.0, 50.0], "B adds 10 to its own X"); } /// #3195: Mask Feather is never negative on the other selected layers either, when a typed `*-1` diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index c5b7223da..87a6d217e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -164,14 +164,15 @@ pub fn hot_number_step_at( /// Mark a queued `prop.set` (with `selected`) as the typed `step` it came from, so every other /// selected layer takes that step from its own value rather than the edited layer's result -/// (#3195): `+n` by the same change (`offset`), `*n` and `/n` by the same `factor`. +/// (#3195): `+n` adds `n` to each (`addend`; not the edited layer's change, which its range may +/// have clamped), `*n` and `/n` multiply each by the same `factor`. pub fn mark_step(action: Option<&mut (String, serde_json::Value)>, step: Option) { let (Some((_, a)), Some(step)) = (action, step) else { return }; - match step.factor() { - Some(k) => a["factor"] = serde_json::json!(k), - None if matches!(step, crate::num_math::Step::Add(_)) => a["offset"] = serde_json::json!(true), + match (step, step.factor()) { + (crate::num_math::Step::Add(n), _) => a["addend"] = serde_json::json!(n), + (_, Some(k)) => a["factor"] = serde_json::json!(k), // (A factor too large to send: the other layers take the typed result.) - None => {} + (_, None) => {} } } diff --git a/crates/ui-egui/tests/it/ui_inline_edit.rs b/crates/ui-egui/tests/it/ui_inline_edit.rs index c845863e4..7d51f0015 100644 --- a/crates/ui-egui/tests/it/ui_inline_edit.rs +++ b/crates/ui-egui/tests/it/ui_inline_edit.rs @@ -480,6 +480,16 @@ fn a_typed_step_applies_to_each_selected_layer() { key(&mut h, Key::Enter); assert_eq!((opacity_of(&h, a), opacity_of(&h, b)), expected, "{text}"); } + // `+10` where the edited layer stops at 100: the others still add 10, not the 5 it moved. + for (l, v) in [(a, 95.0), (b, 30.0)] { + h.state_mut().session.execute("prop.set", json!({"layer": l, "path": "transform/opacity", "value": v})).unwrap(); + } + h.run_steps(5); + let p = center(&h, &format!("timeline.prop.{opacity}.value")); + click(&mut h, p, 1); + type_text(&mut h, "+10"); + key(&mut h, Key::Enter); + assert_eq!((opacity_of(&h, a), opacity_of(&h, b)), (100.0, 40.0)); } /// A click at `p` with `modifiers` held.