Merge pull request #3236 from vangel76/number-field-math-3195

Number fields: typed arithmetic (960/2, (3+4)*2) and a leading *, / or + applied to the value
This commit is contained in:
FredApps
2026-11-14 17:29:47 +01:00
committed by GitHub
14 changed files with 744 additions and 35 deletions
+51 -6
View File
@@ -371,20 +371,41 @@ fn set_selected(s: &mut Session, p: &Value) -> Result<Value> {
};
let follow: Vec<usize> =
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`, `/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<f64> {
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 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, snapped_key_time(s, cid, olid, q, "prop.set").ok().flatten())
&& let Some(mut c) = components_at(s, cid, olid, ouid, at)
{
let change = dims.first().and_then(|&d| Some(n.get(d)? - c.get(d)?)).unwrap_or(0.0);
// The typed components' new values on this layer.
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<Option<f64>> = dims.iter().map(|&d| typed_value(&c, d)).collect();
for &d in &follow {
if let Some(slot) = c.get_mut(d) {
*slot += change;
}
}
for &d in dims {
if let (Some(slot), Some(v)) = (c.get_mut(d), n.get(d)) {
*slot = *v;
for (&d, v) in dims.iter().zip(values) {
if let (Some(slot), Some(v)) = (c.get_mut(d), v) {
*slot = v;
}
}
q["value"] = json!(c);
@@ -421,6 +442,9 @@ 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);
if is_mask_feather(s, cid, olid, ouid) {
non_negative(&mut q["value"]);
}
// (a layer that can't take it, e.g. a clamped or static property, keeps its value)
let _ = set(s, &q);
}
@@ -429,6 +453,26 @@ fn set_selected(s: &mut Session, p: &Value) -> Result<Value> {
if merge_p(p).is_some() { run(s) } else { super::app_more::grouped(s, "Change Property", run) }
}
/// Whether property `uid` of `lid` is a mask's Mask Feather, which is never negative (After
/// Effects; the panels clamp a typed or scrubbed value at 0).
fn is_mask_feather(s: &Session, cid: ItemId, lid: LayerId, uid: Uid) -> bool {
let Some(l) = s.project.comp(cid).and_then(|c| c.layer(lid)) else { return false };
l.props.find(uid).is_some_and(|q| q.match_id == "feather") && l.props.parent_of(uid).is_some_and(|g| matches!(g.kind, GroupKind::Mask { .. }))
}
/// A JSON number, or each number of an array, raised to at least 0.
fn non_negative(v: &mut Value) {
match v {
Value::Number(n) => {
if let Some(x) = n.as_f64().filter(|x| *x < 0.0) {
*v = json!(x.max(0.0));
}
}
Value::Array(a) => a.iter_mut().for_each(non_negative),
_ => {}
}
}
/// `prop.toggleAnimation`; with `selected`, the other selected layers' same property follows.
fn toggle_anim_selected(s: &mut Session, p: &Value) -> Result<Value> {
let twins = selected_twins(s, p, "prop.toggleAnimation")?;
@@ -1446,6 +1490,7 @@ fn convert_one_expr(s: &mut Session, cid: ItemId, lid: LayerId, uid: Uid) -> Res
})
}
/// Returns the command specs for the property commands (get, set, keyframes, expressions and the rest).
pub fn specs() -> Vec<CommandSpec> {
vec![
query!("prop.get", "Get Property", "{layer?, path|prop, time? (comp s)}", get),
@@ -1454,7 +1499,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), 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)}",
"{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
),
+58
View File
@@ -1556,6 +1556,64 @@ fn a_typed_component_applies_only_that_component_to_the_selected_layers() {
assert_eq!(pos(&s, c), vec![1.0, 2.0, 3.0]);
}
/// #3195: a typed `*2` or `/2` with several layers selected scales each layer's own value (in
/// the typed components of a vector), not the edited layer's result.
#[test]
fn a_typed_factor_scales_each_selected_layers_own_value() {
let (mut s, a) = setup();
let b = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/opacity", "value": 40.0})).unwrap();
s.execute("prop.set", json!({"layer": b, "path": "transform/opacity", "value": 30.0})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [10.0, 20.0]})).unwrap();
s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [100.0, 50.0]})).unwrap();
s.execute("layer.select", json!({"layers": [a, b]})).unwrap();
let opacity = prop(&s, a, "transform/opacity").uid;
let undo = s.history.undo.len();
s.execute_checked("prop.set", json!({"layer": a, "prop": opacity, "value": 80.0, "selected": true, "factor": 2.0})).unwrap();
assert_eq!(prop(&s, a, "transform/opacity").value.as_f64(), 80.0);
assert_eq!(prop(&s, b, "transform/opacity").value.as_f64(), 60.0, "B doubles its own 30");
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
let position = prop(&s, a, "transform/position").uid;
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`
/// (the edited layer clamped at 0 by the panel) or a change by the same amount takes theirs
/// below 0.
#[test]
fn a_selected_mask_feather_stays_non_negative() {
let (mut s, a) = setup();
let b = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
for (l, feather) in [(a, [10.0, 20.0]), (b, [4.0, 30.0])] {
s.execute("mask.new", json!({"layer": l, "vertices": [[0, 0], [20, 0], [20, 20]]})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "masks/#1/feather", "value": feather})).unwrap();
}
s.execute("layer.select", json!({"layers": [a, b]})).unwrap();
let feather = |s: &Session, l: u64| prop(s, l, "masks/#1/feather").value.components();
let uid = prop(&s, a, "masks/#1/feather").uid;
// `*-1` into the linked pair: the panel clamps the edited layer at [0, 0].
s.execute_checked("prop.set", json!({"layer": a, "prop": uid, "value": [0.0, 0.0], "selected": true, "dims": [0, 1], "factor": -1.0})).unwrap();
assert_eq!(feather(&s, a), [0.0, 0.0]);
assert_eq!(feather(&s, b), [0.0, 0.0], "B is clamped at 0, not [-4, -30]");
// A change by the same amount (a scrub, or a typed `+`): A by [-10, -15] from [10, 20].
s.execute("edit.undo", json!({})).unwrap();
s.execute_checked("prop.set", json!({"layer": a, "prop": uid, "value": [0.0, 5.0], "selected": true, "offset": true})).unwrap();
assert_eq!(feather(&s, b), [0.0, 15.0], "B's X is clamped at 0, its Y moves by -15");
}
/// #2485: linked Audio Levels typed with several layers selected: each takes the typed Left, and
/// its Right moves by its own change, so every layer keeps its own stereo balance.
#[test]
+6 -1
View File
@@ -447,7 +447,12 @@ fn color_picker(ui: &mut Ui, id: egui::Id, c: &mut [f32; 3], original: [f32; 3])
if ch == Chan::H && v >= 360.0 {
v = 0.0;
}
let drag = egui::DragValue::new(&mut v).range(0.0..=scale).fixed_decimals(0).suffix(suffix).speed(scale / 255.0);
let drag = egui::DragValue::new(&mut v)
.range(0.0..=scale)
.fixed_decimals(0)
.suffix(suffix)
.speed(scale / 255.0)
.custom_parser(|text| crate::num_math::calc(text, None));
if ui.add_sized(vec2(56.0, 18.0), drag).labelled_by(radio.id).changed() {
pick = pick.set(ch, v.round() / scale);
}
+1
View File
@@ -36,6 +36,7 @@ pub mod icons;
pub mod menu_keys;
pub mod menus;
pub mod native_menu;
pub mod num_math;
pub mod panels;
mod pointer_wrap;
pub mod prefs_live;
+401
View File
@@ -0,0 +1,401 @@
//! Basic arithmetic typed into a number field (#3195): `960/2`, `(3+4)*2`, `100*2`, and, in a
//! field that knows the value it showed, a leading operator that applies to it: `*2` doubles,
//! `/2` halves, `+50` adds 50, as the issue's reporter describes for After Effects (not confirmed
//! in public docs). A leading `-` is a negative number (`-50`), not a subtraction. (Hot text used
//! to read `+50` as 50; it now adds 50.)
//!
//! This is a small parser for numbers and `+ - * / ( )` only: no names, no functions, nothing is
//! run as code (expressions, `crates/expr`, are a different thing). The text is bounded in
//! length and nesting, every step must stay finite, and a division by zero is no value. Text it
//! can't read is `None`, so the field keeps its value.
/// The longest text read as arithmetic (characters, after removing spaces).
const MAX_LEN: usize = 256;
/// How deeply brackets and signs (`-(-(…))`, `--5`) may nest.
const MAX_DEPTH: usize = 32;
/// A typed leading operator: what it does to the value the field showed.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Step {
/// `+n`
Add(f64),
/// `*n`
Mul(f64),
/// `/n` (never zero)
Div(f64),
}
impl Step {
/// `current` changed by the step; `None` when that isn't a finite number.
pub fn apply(self, current: f64) -> Option<f64> {
match self {
Step::Add(n) => finite(current + n),
Step::Mul(k) => finite(current * k),
Step::Div(n) => divide(current, n),
}
}
/// The factor a `*` or `/` step multiplies by (`None` for `+`), for other layers that scale
/// their own value by it.
pub fn factor(self) -> Option<f64> {
match self {
Step::Add(_) => None,
Step::Mul(k) => finite(k),
Step::Div(n) => divide(1.0, n),
}
}
}
/// What typed text says: a value, or (with a leading `+`, `*` or `/`) a step from the value shown.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Typed {
Value(f64),
Step(Step),
}
/// Read typed `text`. With `steps`, a leading `+`, `*` or `/` is a [`Step`]; without (a field
/// that reads its text on every keystroke, where a step would compound) a leading `+` is a plain
/// sign and a leading `*` or `/` is no value. Spaces, including thousands groups, are ignored
/// (as egui's own parser did) and `−` (U+2212) is a minus. `,` is a decimal point: hot text
/// always read it so, and the dialog fields, whose egui parser rejected it, now do too (so
/// `1,5` is 1.5 and `1,000` is 1, not a thousand). `None` for anything else that isn't a finite
/// number.
pub fn read(text: &str, steps: bool) -> Option<Typed> {
let s: String = text
.chars()
.filter(|c| !c.is_whitespace())
.map(|c| match c {
'−' => '-',
',' => '.',
c => c,
})
.collect();
if s.is_empty() || s.chars().count() > MAX_LEN {
return None;
}
let first = s.chars().next()?;
if steps && matches!(first, '+' | '*' | '/') {
let operand = parse(s.get(first.len_utf8()..)?)?;
return Some(Typed::Step(match first {
'+' => Step::Add(operand),
'*' => Step::Mul(operand),
_ => Step::Div((operand != 0.0).then_some(operand)?),
}));
}
parse(&s).map(Typed::Value)
}
/// The value typed `text` stands for: [`read`], with a step applied to `current` (the value the
/// field showed; without it a leading `*` or `/` is no value and `+` only a sign).
pub fn calc(text: &str, current: Option<f64>) -> Option<f64> {
match read(text, current.is_some())? {
Typed::Value(v) => Some(v),
Typed::Step(step) => step.apply(current.filter(|c| c.is_finite())?),
}
}
/// The whole of `s` as an expression.
fn parse(s: &str) -> Option<f64> {
let mut p = Parser { chars: s.chars().peekable(), depth: 0 };
let v = p.expr()?;
if p.chars.next().is_some() {
return None;
}
finite(v)
}
/// `a / b`; `None` for a division by zero.
fn divide(a: f64, b: f64) -> Option<f64> {
if b == 0.0 {
return None;
}
finite(a / b)
}
/// A finite result, or `None`.
fn finite(v: f64) -> Option<f64> {
v.is_finite().then_some(v)
}
/// A recursive-descent reader of `expr := term (('+'|'-') term)*`,
/// `term := factor (('*'|'/') factor)*`, `factor := ('+'|'-') factor | '(' expr ')' | number`.
struct Parser<'a> {
chars: std::iter::Peekable<std::str::Chars<'a>>,
/// The brackets and signs open around the current point.
depth: usize,
}
impl Parser<'_> {
/// Reads a sum or difference of terms, left to right.
/// Returns `None` on a syntax error or a non-finite result.
fn expr(&mut self) -> Option<f64> {
let mut v = self.term()?;
while let Some(&op) = self.chars.peek() {
match op {
'+' => {
self.chars.next();
v = finite(v + self.term()?)?;
}
'-' => {
self.chars.next();
v = finite(v - self.term()?)?;
}
_ => break,
}
}
Some(v)
}
/// Reads a product or quotient of factors, left to right.
/// Returns `None` on a syntax error, a division by zero or a non-finite result.
fn term(&mut self) -> Option<f64> {
let mut v = self.factor()?;
while let Some(&op) = self.chars.peek() {
match op {
'*' => {
self.chars.next();
v = finite(v * self.factor()?)?;
}
'/' => {
self.chars.next();
v = divide(v, self.factor()?)?;
}
_ => break,
}
}
Some(v)
}
/// Reads a signed factor, a bracketed expression or a number.
/// Nesting deeper than `MAX_DEPTH` gives `None`.
fn factor(&mut self) -> Option<f64> {
let c = *self.chars.peek()?;
match c {
'+' | '-' | '(' => {
self.depth = self.depth.checked_add(1)?;
if self.depth > MAX_DEPTH {
return None;
}
self.chars.next();
let v = if c == '(' {
let v = self.expr()?;
if self.chars.next() != Some(')') {
return None;
}
v
} else {
let v = self.factor()?;
if c == '-' { -v } else { v }
};
self.depth = self.depth.saturating_sub(1);
Some(v)
}
_ => self.number(),
}
}
/// Digits with at most one decimal point and an optional exponent (`12`, `1.5`, `.5`, `2.`,
/// `2e2`, `1.5E-3`).
fn number(&mut self) -> Option<f64> {
let mut text = String::new();
let mut point = false;
while let Some(&c) = self.chars.peek() {
if c.is_ascii_digit() {
text.push(c);
} else if c == '.' && !point {
point = true;
text.push(c);
} else {
break;
}
self.chars.next();
}
if !text.chars().any(|c| c.is_ascii_digit()) {
return None;
}
if let Some(&e) = self.chars.peek()
&& matches!(e, 'e' | 'E')
{
self.chars.next();
text.push('e');
if let Some(&sign) = self.chars.peek()
&& matches!(sign, '+' | '-')
{
self.chars.next();
text.push(sign);
}
let mut digits = false;
while let Some(&c) = self.chars.peek() {
if !c.is_ascii_digit() {
break;
}
digits = true;
text.push(c);
self.chars.next();
}
if !digits {
return None;
}
}
finite(text.parse::<f64>().ok()?)
}
}
#[cfg(test)]
mod tests {
use super::calc as eval;
/// Evaluates `text` with no current value, so a leading operator has nothing to apply to.
fn abs(text: &str) -> Option<f64> {
eval(text, None)
}
#[test]
fn plain_numbers_read_as_before() {
assert_eq!(abs("100"), Some(100.0));
assert_eq!(abs("-12.5"), Some(-12.5));
assert_eq!(abs(" 12,5 "), Some(12.5), "a decimal comma");
assert_eq!(abs("1 000"), Some(1000.0), "a thousands space");
assert_eq!(abs("−3"), Some(-3.0), "the minus sign U+2212");
assert_eq!(abs("1e3"), Some(1000.0));
assert_eq!(abs(".5"), Some(0.5));
}
#[test]
fn arithmetic() {
assert_eq!(abs("960/2"), Some(480.0));
assert_eq!(abs("100*2"), Some(200.0));
assert_eq!(abs("(3+4)*2"), Some(14.0));
assert_eq!(abs("3+4*2"), Some(11.0), "* before +");
assert_eq!(abs("10-2-3"), Some(5.0), "left to right");
assert_eq!(abs("8/4/2"), Some(1.0), "left to right");
assert_eq!(abs("-(2+3)"), Some(-5.0));
assert_eq!(abs("2*-3"), Some(-6.0));
assert_eq!(abs("1.5 * 2"), Some(3.0));
assert_eq!(abs("((((1))))"), Some(1.0));
assert_eq!(abs("100+50"), Some(150.0));
assert_eq!(abs("100-50"), Some(50.0));
assert_eq!(abs("2e2*2"), Some(400.0), "an exponent inside arithmetic");
assert_eq!(abs("1E-3*1000"), Some(1.0));
assert_eq!(abs("1e+2/4"), Some(25.0));
}
#[test]
fn steps_are_read_as_steps() {
use super::{Step, Typed, read};
assert_eq!(read("*2", true), Some(Typed::Step(Step::Mul(2.0))));
assert_eq!(read("/4", true), Some(Typed::Step(Step::Div(4.0))));
assert_eq!(read("+(1+2)", true), Some(Typed::Step(Step::Add(3.0))));
assert_eq!(read("-5", true), Some(Typed::Value(-5.0)));
assert_eq!(read("+5", false), Some(Typed::Value(5.0)));
assert_eq!(read("/0", true), None);
assert_eq!(Step::Div(4.0).factor(), Some(0.25));
assert_eq!(Step::Mul(3.0).factor(), Some(3.0));
assert_eq!(Step::Add(3.0).factor(), None);
assert_eq!(Step::Div(1e-320).factor(), None, "a factor that overflows");
assert_eq!(Step::Div(3.0).apply(3.0), Some(1.0), "a division, not a reciprocal factor");
}
#[test]
fn a_leading_operator_applies_to_the_current_value() {
assert_eq!(eval("*2", Some(100.0)), Some(200.0));
assert_eq!(eval("/2", Some(100.0)), Some(50.0));
assert_eq!(eval("+50", Some(100.0)), Some(150.0));
assert_eq!(eval("*(1+1)", Some(3.0)), Some(6.0));
assert_eq!(eval("+-50", Some(100.0)), Some(50.0));
// A leading minus is a negative number, not a subtraction.
assert_eq!(eval("-50", Some(100.0)), Some(-50.0));
assert_eq!(eval("-50*2", Some(100.0)), Some(-100.0));
// Without the current value there is nothing to apply it to; a `+` is only a sign.
assert_eq!(eval("*2", None), None);
assert_eq!(eval("/2", None), None);
assert_eq!(eval("+50", None), Some(50.0));
assert_eq!(eval("*2", Some(f64::NAN)), None);
assert_eq!(eval("+1", Some(f64::INFINITY)), None);
assert_eq!(eval("/0", Some(100.0)), None);
assert_eq!(eval("/(1-1)", Some(100.0)), None);
assert_eq!(eval("*", Some(100.0)), None);
assert_eq!(eval("*1e400", Some(1.0)), None);
// Overflow past the largest finite value.
assert_eq!(eval(&format!("*{}", "9".repeat(20)), Some(1e300)), None);
assert_eq!(eval("/0.0000000001", Some(1e300)), None);
}
#[test]
fn invalid_or_hostile_text_is_no_value() {
for text in [
"", " ", "*", "/", "1+", "+", "-", "2**3", "2//3", "1/0", "0/0", "-1/0", "1/(2-2)", "(1", "1)", "()", "(1+2", "abc", "NaN", "nan", "inf", "-inf",
"infinity", "1e999", "-1e999", "1..2", "1.2.3", ".", "1e", "2x", "x*2", "1+a", "5;rm", "1,2,3", "0x10", "1_000", "%", "50%",
] {
assert_eq!(abs(text), None, "{text:?}");
assert_eq!(eval(text, Some(10.0)), None, "{text:?} with a current value");
}
// Far too long: rejected by length before any reading.
assert_eq!(abs(&format!("{}1{}", "(".repeat(1000), ")".repeat(1000))), None);
assert_eq!(abs(&format!("{}5", "-".repeat(10_000))), None);
assert_eq!(abs(&"(".repeat(10_000)), None);
assert_eq!(abs(&"1".repeat(10_000)), None);
assert_eq!(abs(&"1+".repeat(5_000)), None);
// Short enough, but nested past the depth bound.
assert_eq!(abs(&format!("{}1{}", "(".repeat(100), ")".repeat(100))), None);
assert_eq!(abs(&format!("{}5", "-".repeat(200))), None);
assert_eq!(abs(&format!("2*{}5", "-+".repeat(100))), None);
// Nesting within the bound reads.
assert_eq!(abs(&format!("{}1{}", "(".repeat(20), ")".repeat(20))), Some(1.0));
assert_eq!(abs("--5"), Some(5.0));
// Overflow on the way is no value.
assert_eq!(abs("1e308*10"), None);
assert_eq!(abs("1e308+1e308"), None);
assert_eq!(abs("1e200*1e200/1e200"), None, "an infinite step is rejected even if the result would be finite");
// The largest numbers the length allows still multiply to a finite value.
let big = "9".repeat(120);
assert!(abs(&format!("{big}*{big}")).is_some_and(f64::is_finite));
}
/// The result is always finite (no NaN reaches a property).
#[test]
fn results_are_finite() {
for text in ["0*1e308", "1e-320/1e10", "0-0", "-0"] {
if let Some(v) = abs(text) {
assert!(v.is_finite(), "{text:?} → {v}");
}
}
}
/// A standard numeric editor (dialogs) reads typed arithmetic too.
/// (egui's own parser read `960/2` as no value, so the field stayed 10.)
#[test]
fn a_drag_value_reads_typed_arithmetic() {
use egui::{Context, Event, Key, Modifiers, PointerButton, Pos2, RawInput, Rect, pos2, vec2};
let ctx = Context::default();
let mut value = 10.0_f64;
let mut time = 0.0;
let field = Rect::from_min_size(pos2(100.0, 100.0), vec2(80.0, 20.0));
let mut frame = |events: Vec<Event>, value: &mut f64| {
time += 0.02;
let raw = RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0))),
time: Some(time),
events,
focused: true,
..Default::default()
};
let mut out = ctx.run_ui(raw, |ui| {
ui.put(field, crate::widgets::drag_value(egui::DragValue::new(&mut *value).speed(1.0)));
});
out.textures_delta.clear();
};
let p = field.center();
frame(vec![], &mut value);
frame(
vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }],
&mut value,
);
frame(vec![Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }], &mut value);
frame(vec![], &mut value);
frame(vec![Event::Text("960/2".into())], &mut value);
frame(vec![Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }], &mut value);
frame(vec![], &mut value);
assert_eq!(value, 480.0);
}
}
@@ -260,20 +260,23 @@ fn value_mask(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &
}
}
/// Draws an angle property's value cell as revolutions plus degrees and queues a set action when it is edited.
fn value_angle(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) {
let Value::Scalar(v) = value else {
return;
};
let PropCx { uid, cy, vx, prop, .. } = *cx;
let (rr, dr, nv) = fw::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::unique(("ec-v", uid)), *v, 1, &cx.value_tokens());
let (rr, dr, nv, step) = fw::angle_field_step(ui, pos2(vx, cy - 9.0), egui::Id::unique(("ec-v", uid)), *v, 1, &cx.value_tokens());
app.auto.add(&format!("effectControls.prop.{uid}.value"), rr.union(dr), &fw::format_angle(*v, 1));
app.auto.add(&format!("effectControls.prop.{uid}.revolutions"), rr, &prop.name);
if let Some(nv) = nv {
cx.set(actions, json!(nv));
widgets::mark_step(actions.last_mut(), step);
}
cx.twirl_open(app, ui);
}
/// Draws a single-number property's value cell with its range, unit suffix and scrub speed, and queues a set action when it is edited.
fn value_scalar(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) {
let Value::Scalar(v) = value else {
return;
@@ -288,7 +291,7 @@ fn value_scalar(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value:
_ => "",
};
let speed = widgets::slider_scrub_speed(&prop.ui);
let (vr, nv, _) = widgets::hot_number_at(
let (vr, nv, _, step) = widgets::hot_number_step_at(
ui,
pos2(vx, cy - 9.0),
egui::Id::unique(("ec-v", uid)),
@@ -299,6 +302,7 @@ fn value_scalar(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value:
app.auto.add(&format!("effectControls.prop.{uid}.value"), vr, &prop.name);
if let Some(nv) = nv {
cx.set(actions, json!(nv));
widgets::mark_step(actions.last_mut(), step);
}
// AE hides a slider param's slider until its twirl is opened (see `slider_row`).
if matches!(prop.ui, ParamUi::Slider { .. }) {
@@ -306,6 +310,8 @@ fn value_scalar(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value:
}
}
/// Draws one numeric cell per component of a point or vector property, with the pick crosshair for points.
/// Returns the cell rectangles.
fn value_vector(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) -> Vec<Rect> {
let mut dims = vec![];
let PropCx { t, uid, cy, vx, layer, prop, .. } = *cx;
@@ -326,7 +332,7 @@ fn value_vector(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value:
x += 22.0;
}
for d in 0..n.min(c.len()) {
let (vr, nv, _) = widgets::hot_number_at(
let (vr, nv, _, step) = widgets::hot_number_step_at(
ui,
pos2(x, cy - 9.0),
egui::Id::unique(("ec-v", uid, d)),
@@ -345,6 +351,7 @@ fn value_vector(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value:
if let Some((_, a)) = actions.last_mut() {
a["dims"] = json!([d]);
}
widgets::mark_step(actions.last_mut(), step);
}
x = vr.max.x + 8.0;
}
+7 -1
View File
@@ -399,7 +399,13 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt
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));
let r = ui.add(
egui::DragValue::new(&mut x)
.speed(0.5)
.suffix(if d + 1 == comps.len() { "%" } else { "" })
.max_decimals(1)
.custom_parser(|text| crate::num_math::calc(text, None)),
);
app.auto.add(&format!("{auto}.{d}"), r.rect, &p.name);
rect = rect.union(r.rect);
if r.changed() {
+25 -7
View File
@@ -8,6 +8,7 @@
use effectcraft_engine::geom::{Mat3, vec2 as gv2};
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use crate::num_math::Step;
use crate::theme::Tokens;
use crate::widgets;
@@ -56,18 +57,35 @@ fn degrees_edit(v: f64, shown: f64, new: f64, dragging: bool) -> f64 {
/// are hot numbers of their own (drag to scrub, click to type) and changing one keeps the other.
/// Returns the revolutions' rect, the degrees' rect and the new angle when either changed.
pub fn angle_field(ui: &mut egui::Ui, at: Pos2, id: egui::Id, v: f64, decimals: usize, t: &Tokens) -> (Rect, Rect, Option<f64>) {
let (rr, dr, out, _) = angle_field_step(ui, at, id, v, decimals, t);
(rr, dr, out)
}
/// [`angle_field`], also returning a typed `+n` into the degrees (#3195): other selected layers
/// can turn their own angle by it. A typed step applies to the signed remainder, so `+10` on
/// `0x-30°` is `0x-20°`.
pub fn angle_field_step(ui: &mut egui::Ui, at: Pos2, id: egui::Id, v: f64, decimals: usize, t: &Tokens) -> (Rect, Rect, Option<f64>, Option<Step>) {
let (rev, deg) = split_angle(v);
let (rr, new_rev, _) = widgets::hot_int_at(ui, at, id.with("rev"), rev, 0.1, "x", t);
let sign = ui.painter().text(pos2(rr.max.x - 2.0, rr.center().y), Align2::LEFT_CENTER, if deg < 0.0 { "-" } else { "+" }, Tokens::ui(12.0), t.hot_text);
let (dr, new_deg, _) =
widgets::hot_number_at(ui, pos2(sign.max.x - 2.0, at.y), id, deg.abs(), &widgets::HotSpec { speed: 0.5, range: (-1e9, 1e9), decimals, suffix: "°" }, t);
let (dr, new_deg, _, step) = widgets::hot_number_step_at(
ui,
pos2(sign.max.x - 2.0, at.y),
id,
deg.abs(),
&widgets::HotSpec { speed: 0.5, range: (-1e9, 1e9), decimals, suffix: "°" },
t,
);
let dragging = new_deg.is_some() && ui.ctx().is_being_dragged(id);
let out = match (new_rev, new_deg) {
(Some(r), _) => Some(r as f64 * 360.0 + deg),
(None, Some(d)) => Some(degrees_edit(v, deg.abs(), d, dragging)),
(None, None) => None,
let out = match (new_rev, new_deg, step) {
(Some(r), _, _) => Some(r as f64 * 360.0 + deg),
// (The field shows the remainder's size; the step goes from the signed remainder.)
(None, Some(_), Some(s)) => s.apply(deg).map(|d| rev as f64 * 360.0 + d),
(None, Some(d), None) => Some(degrees_edit(v, deg.abs(), d, dragging)),
(None, None, _) => None,
};
(rr, dr, out)
let step = step.filter(|s| matches!(s, Step::Add(_)) && new_rev.is_none() && out.is_some());
(rr, dr, out, step)
}
/// Dial angle (degrees, 0 = up, clockwise) of `p` around `c` in screen space.
+16 -2
View File
@@ -440,7 +440,14 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
}
.unwrap_or(0.0);
let mut pct = pos * 100.0;
let r = ui.add(egui::DragValue::new(&mut pct).range(0.0..=100.0).speed(0.5).max_decimals(1).suffix("%"));
let r = ui.add(
egui::DragValue::new(&mut pct)
.range(0.0..=100.0)
.speed(0.5)
.max_decimals(1)
.suffix("%")
.custom_parser(|text| crate::num_math::calc(text, None)),
);
app.auto.add("dialog.gradient.location", r.rect, "Location");
if r.changed() {
app.dialog_state.gradient.move_to(track, id, pct / 100.0);
@@ -452,7 +459,14 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
Track::Opacity => {
ui.label(tr("Opacity:"));
let mut a = app.dialog_state.gradient.opacities.get(i).map_or(1.0, |s| s.value) as f64 * 100.0;
let r = ui.add(egui::DragValue::new(&mut a).range(0.0..=100.0).speed(0.5).max_decimals(1).suffix("%"));
let r = ui.add(
egui::DragValue::new(&mut a)
.range(0.0..=100.0)
.speed(0.5)
.max_decimals(1)
.suffix("%")
.custom_parser(|text| crate::num_math::calc(text, None)),
);
app.auto.add("dialog.gradient.opacity", r.rect, "Opacity");
if r.changed() {
app.dialog_state.gradient.set_opacity(id, (a / 100.0) as f32);
+6 -3
View File
@@ -292,16 +292,17 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
match &value {
Value::Scalar(v) if pr.name == "Rotation" || pr.name.ends_with(" Rotation") => {
let (_, deg) = super::fx_widgets::split_angle(*v);
let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::unique(("props-v", uid)), *v, decimals(deg), &vt);
let (rr, dr, nv, step) = super::fx_widgets::angle_field_step(ui, pos2(vx, cy - 9.0), egui::Id::unique(("props-v", uid)), *v, decimals(deg), &vt);
app.auto.add(&format!("{base}.value"), dr, &pr.name);
app.auto.add(&format!("{base}.revolutions"), rr, &pr.name);
if let Some(nv) = nv {
set(actions, json!(nv));
widgets::mark_step(actions.last_mut(), step);
}
}
Value::Scalar(v) => {
let range = if pr.name == "Opacity" { (0.0, 100.0) } else { (-1e9, 1e9) };
let (vr, nv, _) = widgets::hot_number_at(
let (vr, nv, _, step) = widgets::hot_number_step_at(
ui,
pos2(vx, cy - 9.0),
egui::Id::unique(("props-v", uid)),
@@ -312,6 +313,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
app.auto.add(&format!("{base}.value"), vr, &pr.name);
if let Some(nv) = nv {
set(actions, json!(nv));
widgets::mark_step(actions.last_mut(), step);
}
}
Value::Vec2(_) | Value::Vec3(_) => {
@@ -333,7 +335,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
}
}
for d in 0..n.min(c.len()) {
let (vr, nv, _) = widgets::hot_number_at(
let (vr, nv, _, step) = widgets::hot_number_step_at(
ui,
pos2(x, cy - 9.0),
egui::Id::unique(("props-v", uid, d)),
@@ -358,6 +360,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
if let Some((_, a)) = actions.last_mut() {
a["dims"] = json!((from..from + shown).collect::<Vec<_>>());
}
widgets::mark_step(actions.last_mut(), step);
}
x = vr.max.x + 10.0;
}
+6 -3
View File
@@ -5913,17 +5913,18 @@ fn scalar_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>
_ => (-1e9, 1e9, 1, 1.0),
};
if matches!(prop.ui, ParamUi::Angle) {
let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(x, y), egui::Id::unique(("v", uid, 0)), *v, 1, &vt);
let (rr, dr, nv, step) = super::fx_widgets::angle_field_step(ui, pos2(x, y), egui::Id::unique(("v", uid, 0)), *v, 1, &vt);
app.auto.add(&format!("timeline.prop.{uid}.value"), dr, &prop.name);
app.auto.add(&format!("timeline.prop.{uid}.revolutions"), rr, &prop.name);
if let Some(nv) = nv {
ve.set(actions, json!(nv));
widgets::mark_step(actions.last_mut(), step);
}
} else {
// A Range Selector's Start, End and Offset count characters with Units = Index (#1814).
let pct = (matches!(prop.ui, ParamUi::Percent) || prop.match_id == "opacity") && !effectcraft_engine::render::text::in_index_units(layer, uid);
let suffix = if pct { "%" } else { "" };
let (r, nv, _) = widgets::hot_number_at(
let (r, nv, _, step) = widgets::hot_number_step_at(
ui,
pos2(x, y),
egui::Id::unique(("v", uid, 0)),
@@ -5934,6 +5935,7 @@ fn scalar_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
if let Some(nv) = nv {
ve.set(actions, json!(nv));
widgets::mark_step(actions.last_mut(), step);
}
}
}
@@ -5972,7 +5974,7 @@ fn vector_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>
}
for d in 0..n.min(c.len()) {
let suffix = if pct && d + 1 == n { "%" } else { "" };
let (r, nv, _) = widgets::hot_number_at(
let (r, nv, _, step) = widgets::hot_number_step_at(
ui,
pos2(x, y),
egui::Id::unique(("v", uid, d)),
@@ -6006,6 +6008,7 @@ fn vector_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>
a["dims"] = json!((from..from + shown).collect::<Vec<_>>());
}
}
widgets::mark_step(actions.last_mut(), step);
}
x = r.max.x + if d + 1 < n { 2.0 } else { 0.0 };
if d + 1 < n {
+54 -9
View File
@@ -63,8 +63,23 @@ pub struct HotSpec<'a> {
/// "Hot text": a blue number you drag horizontally to scrub, click to type. Tab / Shift+Tab
/// while typing commits and types in the panel's next / previous one, as in After Effects.
/// Typed text may be arithmetic, `960/2` or `*2` for twice the value ([`crate::num_math`]).
/// Returns (response, Some(new value) when changed, drag finished).
pub fn hot_number_at(ui: &mut Ui, rect_min: egui::Pos2, id: egui::Id, value: f64, spec: &HotSpec, t: &Tokens) -> (Rect, Option<f64>, bool) {
let (rect, out, done, _) = hot_number_step_at(ui, rect_min, id, value, spec, t);
(rect, out, done)
}
/// [`hot_number_at`], also returning the typed step (`*2`, `+50`) the new value came from, so
/// that other selected layers can take the same step from their own value (#3195).
pub fn hot_number_step_at(
ui: &mut Ui,
rect_min: egui::Pos2,
id: egui::Id,
value: f64,
spec: &HotSpec,
t: &Tokens,
) -> (Rect, Option<f64>, bool, Option<crate::num_math::Step>) {
let HotSpec { speed, range, decimals, suffix } = *spec;
let editing_id = id.with("editing");
let mut editing: Option<String> = ui.data(|d| d.get_temp(editing_id));
@@ -87,14 +102,18 @@ pub fn hot_number_at(ui: &mut Ui, rect_min: egui::Pos2, id: egui::Id, value: f64
.lock_focus(true),
);
let tab = r.has_focus().then(|| ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab).then_some(i.modifiers.shift))).flatten();
let mut out = None;
let (mut out, mut step) = (None, None);
if r.lost_focus() || tab.is_some() {
let txt = buf.trim().trim_end_matches(suffix).trim().replace(',', ".");
// Typed arithmetic reads too (`960/2`), a leading `*`, `/` or `+` applied to the value
// shown (#3195).
if !ui.input(|i| i.key_pressed(egui::Key::Escape))
&& let Ok(v) = txt.parse::<f64>()
&& v.is_finite()
&& let Some(typed) = crate::num_math::read(buf.trim().trim_end_matches(suffix), true)
{
out = Some(v.clamp(range.0, range.1));
let v = match typed {
crate::num_math::Typed::Value(v) => Some(v),
crate::num_math::Typed::Step(s) => s.apply(value).inspect(|_| step = Some(s)),
};
out = v.map(|v| v.clamp(range.0, range.1));
}
ui.data_mut(|d| d.remove::<String>(editing_id));
if let Some(back) = tab {
@@ -106,7 +125,7 @@ pub fn hot_number_at(ui: &mut Ui, rect_min: egui::Pos2, id: egui::Id, value: f64
keep_focus(ui.ctx(), &r, &buf);
ui.data_mut(|d| d.insert_temp(editing_id, buf));
}
return (er, out, out.is_some());
return (er, out, out.is_some(), step);
}
let resp = ui.interact(rect, id, Sense::click_and_drag());
crate::pointer_wrap::track(&resp);
@@ -140,7 +159,21 @@ pub fn hot_number_at(ui: &mut Ui, rect_min: egui::Pos2, id: egui::Id, value: f64
if resp.clicked() {
ui.data_mut(|d| d.insert_temp(editing_id, format!("{value:.decimals$}")));
}
(rect, out, resp.drag_stopped())
(rect, out, resp.drag_stopped(), None)
}
/// 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` 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<crate::num_math::Step>) {
let (Some((_, a)), Some(step)) = (action, step) else { return };
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) => {}
}
}
/// Ctrl+Alt (Cmd+Opt on macOS) held while scrubbing hot text: After Effects' Proportional
@@ -162,10 +195,19 @@ pub fn slider_scrub_speed(ui: &ParamUi) -> f64 {
/// [`hot_number_at`] for a whole number (an angle's revolutions): a drag steps it once every
/// `1 / speed` pixels however slowly the pointer moves (the fractions add up between frames),
/// and a typed value is truncated. Returns (rect, Some(new value) when changed, drag finished).
/// and a typed value is rounded (`5/2` is 3). Returns (rect, Some(new value) when changed, drag
/// finished).
pub fn hot_int_at(ui: &mut Ui, rect_min: egui::Pos2, id: egui::Id, value: i64, speed: f64, suffix: &str, t: &Tokens) -> (Rect, Option<i64>, bool) {
let (rect, nv, done) = hot_number_at(ui, rect_min, id, value as f64, &HotSpec { speed, range: (-1e6, 1e6), decimals: 0, suffix }, t);
let acc_id = id.with("acc");
if let Some(n) = nv
&& !ui.ctx().is_being_dragged(id)
{
// Typed (a drag changes the value only while it is dragged). (Within ±1e6: the cast is exact.)
ui.data_mut(|d| d.remove::<f64>(acc_id));
let typed = n.round() as i64;
return (rect, (typed != value).then_some(typed), done);
}
let acc = ui.data(|d| d.get_temp::<f64>(acc_id)).unwrap_or(0.0) + nv.map_or(0.0, |n| n - value as f64);
// (Ten 0.1 steps add up to just under 1.)
let step = (acc + acc.signum() * 1e-9).trunc();
@@ -177,8 +219,11 @@ pub fn hot_int_at(ui: &mut Ui, rect_min: egui::Pos2, id: egui::Id, value: i64, s
(rect, (step != 0.0).then(|| value.saturating_add(step as i64)), done)
}
/// Standard numeric editors use the same continuous native scrubbing as hot text.
/// Standard numeric editors use the same continuous native scrubbing as hot text, and read typed
/// arithmetic (`960/2`) too. They read their text on every keystroke, so a leading `*` or `/`
/// (relative to the value) is left to hot text, where it would otherwise compound (#3195).
pub fn drag_value(value: egui::DragValue<'_>) -> impl egui::Widget {
let value = value.custom_parser(|text| crate::num_math::calc(text, None));
move |ui: &mut Ui| {
let response = ui.add(value);
crate::pointer_wrap::track(&response);
+15
View File
@@ -172,6 +172,21 @@ fn typing_replaces_the_clicked_value() {
}
}
/// #3195: a typed `+10` turns the angle by 10°, from its signed remainder: `0x-30°` becomes
/// `0x-20°` (the degrees field shows the remainder's size, 30, which would have given -40°).
#[test]
fn a_typed_step_applies_to_the_signed_angle() {
for panel in [Panel::EffectControls, Panel::Timeline, Panel::Properties] {
for (text, expected) in [("+10", -20.0), ("*2", -60.0), ("/3", -10.0)] {
let (mut h, layer, uid) = harness(panel, -30.0);
let p = component(&h, panel, uid, false);
begin_edit(&mut h, p, text);
key(&mut h, Key::Enter, Modifiers::NONE);
assert_eq!(angle(&h, layer, uid), expected, "{panel:?}: {text}");
}
}
}
fn commit_edits(click_away: bool) {
for panel in [Panel::EffectControls, Panel::Timeline, Panel::Properties] {
for (revolutions, text, expected) in [(true, "3", 1110.5), (false, "45.5", 405.5)] {
+88
View File
@@ -404,6 +404,94 @@ fn tab_commits_a_value_and_moves_to_the_next() {
assert_eq!(value(&h, "effects/#2/slider"), [42.0]);
}
/// #3195: a value field reads typed arithmetic, as in After Effects: `960/2` sets 480, a leading
/// `*` applies to the value shown, and text that is no number (a division by zero) keeps the
/// value with no undo step. In the Timeline and in Effect Controls.
#[test]
fn typed_arithmetic_sets_a_value() {
let (mut h, _) = harness();
let (layer, transform) = {
let s = &mut h.state_mut().session;
let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.select", json!({"layers": [l]})).unwrap();
s.execute("effect.apply", json!({"layers": [l], "effect": "Point Control"})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [160.0, 90.0]})).unwrap();
let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.sub("transform").unwrap().uid;
(l, tr)
};
h.state_mut().ui.timeline.open_layers.insert(layer);
h.state_mut().ui.timeline.open_groups.insert(transform);
h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::EffectControls);
h.run_steps(3);
let props =
|h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.clone();
let value = |h: &Harness<'_, EffectcraftApp>, path: &str| props(h).prop(path).unwrap().value.components();
let position = props(&h).prop("transform/position").unwrap().uid;
assert_eq!(value(&h, "transform/position")[..2], [160.0, 90.0]);
let type_into = |h: &mut Harness<'_, EffectcraftApp>, id: &str, text: &str| {
// (Settle first: a click right after the last one's would count as part of a double click.)
h.run_steps(5);
let p = center(h, id);
click(h, p, 1);
type_text(h, text);
key(h, Key::Enter);
};
type_into(&mut h, &format!("timeline.prop.{position}.value.0"), "960/2");
assert_eq!(value(&h, "transform/position")[..2], [480.0, 90.0], "960/2 is 480");
type_into(&mut h, &format!("timeline.prop.{position}.value.1"), "*2");
assert_eq!(value(&h, "transform/position")[..2], [480.0, 180.0], "*2 doubles the value shown");
let undo = h.state().session.history.undo.len();
type_into(&mut h, &format!("timeline.prop.{position}.value.0"), "1/0");
assert_eq!(value(&h, "transform/position")[..2], [480.0, 180.0], "a division by zero keeps the value");
assert_eq!(h.state().session.history.undo.len(), undo, "and adds no undo step");
let point = props(&h).prop("effects/#1/point").unwrap().uid;
type_into(&mut h, &format!("effectControls.prop.{point}.value.1"), "(3+4)*2");
assert_eq!(value(&h, "effects/#1/point")[1], 14.0);
}
/// #3195: with several layers selected, a typed `+10` or `*2` takes each layer's own value one
/// step on; it doesn't give them all the edited layer's result.
#[test]
fn a_typed_step_applies_to_each_selected_layer() {
let (mut h, _) = harness();
let (a, b, transform) = {
let s = &mut h.state_mut().session;
let mut ids = vec![];
for (name, opacity) in [("A", 40.0), ("B", 30.0)] {
let l = s.execute("layer.newSolid", json!({"name": name, "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": l, "path": "transform/opacity", "value": opacity})).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 opacity_of = |h: &Harness<'_, EffectcraftApp>, l: u64| {
h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.prop("transform/opacity").unwrap().value.as_f64()
};
let opacity = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(a)).unwrap().props.prop("transform/opacity").unwrap().uid;
for (text, expected) in [("+10", (50.0, 40.0)), ("*2", (100.0, 80.0)), ("/4", (25.0, 20.0)), ("60", (60.0, 60.0))] {
let p = center(&h, &format!("timeline.prop.{opacity}.value"));
click(&mut h, p, 1);
type_text(&mut h, text);
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.
fn click_with(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, modifiers: Modifiers) {
h.input_mut().events.push(Event::PointerMoved(p));