From 06c275d1363d9ec18a43c1e145b4d2faf5af551c Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 02:15:42 +0300 Subject: [PATCH] Animations: check effect options and accept direction words animation.add stored any `option` string as given, and the renderer reads direction codes letter by letter, so "fromBottom" (r, t, b) flew in from the bottom right. The option is now checked against the effect's list; direction words ("fromBottom", "topLeft", "upRight") map to the stored codes ("b", "lt", "ru"), and anything else is an error listing the valid options. animation.options applies the option to each target in its own effect's terms and leaves targets without such an option alone. Co-authored-by: Cursor --- crates/engine/src/cmd/animation.rs | 83 ++++++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/animation.rs b/crates/engine/src/cmd/animation.rs index 8b6fe5a7..dc6b9704 100644 --- a/crates/engine/src/cmd/animation.rs +++ b/crates/engine/src/cmd/animation.rs @@ -82,6 +82,51 @@ fn start_of(s: &str) -> AnimStart { } } +/// The effect option `given` as one of `allowed`. Besides the stored codes ("b", "lt", "u", …) +/// it takes the direction words people type: "fromBottom", "bottom", "fromTopLeft", +/// "upRight". `None` when it matches none of them: the renderer reads direction codes letter +/// by letter, so an unchecked "fromBottom" would fly in from the right and the bottom. +fn resolve_option(allowed: &[&str], given: &str) -> Option { + if allowed.is_empty() || allowed.contains(&given) { + return Some(given.to_string()); + } + let low = given.to_ascii_lowercase(); + if let Some(o) = allowed.iter().find(|o| o.to_ascii_lowercase() == low) { + return Some(o.to_string()); + } + let mut rest = low.strip_prefix("from").unwrap_or(&low).trim_start_matches(['-', '_', ' ']); + // Direction words in order, each as both its edge letter and its arrow letter. + let mut dirs: Vec<(char, char)> = Vec::new(); + while !rest.is_empty() { + let words: [(&str, (char, char)); 8] = [ + ("bottom", ('b', 'd')), + ("down", ('b', 'd')), + ("top", ('t', 'u')), + ("up", ('t', 'u')), + ("left", ('l', 'l')), + ("right", ('r', 'r')), + ("-", ('-', '-')), + ("_", ('-', '-')), + ]; + let (w, d) = words.iter().find(|(w, _)| rest.starts_with(w))?; + if d.0 != '-' { + dirs.push(*d); + } + rest = &rest[w.len()..]; + } + if dirs.is_empty() || dirs.len() > 2 { + return None; + } + let mut cands: Vec = Vec::new(); + for pick in [|d: &(char, char)| d.0, |d: &(char, char)| d.1] { + let code: String = dirs.iter().map(pick).collect(); + let rev: String = code.chars().rev().collect(); + cands.push(code); + cands.push(rev); + } + cands.into_iter().find(|c| allowed.contains(&c.as_str())) +} + fn build(p: &Value) -> Result { let effect = str_param(p, "effect").ok_or_else(|| bad("animation", "missing `effect`"))?; let class = class_of(str_param(p, "class")); @@ -93,7 +138,12 @@ fn build(p: &Value) -> Result { let mut a = Animation { class: info.2, effect: info.0.to_string(), - option: str_param(p, "option").map(String::from).or_else(|| info.5.first().map(|o| o.to_string())).unwrap_or_default(), + option: match str_param(p, "option") { + Some(o) => { + resolve_option(info.5, o).ok_or_else(|| bad("animation", format!("option `{o}` is not one of {:?} for `{}`", info.5, info.0)))? + } + None => info.5.first().map(|o| o.to_string()).unwrap_or_default(), + }, duration_ms: p.get("duration").and_then(Value::as_u64).map(|v| v.min(600_000) as u32).unwrap_or(info.4), delay_ms: p.get("delay").and_then(Value::as_u64).map(|v| v.min(600_000) as u32).unwrap_or(0), preset_id: Some(info.3), @@ -270,9 +320,14 @@ fn options(s: &mut Session, p: &Value) -> Result { }); each_target(s, p, |a| { if let Some(o) = &option { - a.option = o.clone(); - if a.class == AnimClass::Path { - a.path = Some(default_path(&a.effect, o)); + // Each target takes the option in its own effect's terms; one it has no such + // option for keeps its own. + let allowed = animation_info(&a.effect, a.class).map(|i| i.5).unwrap_or(&[]); + if let Some(o) = resolve_option(allowed, o) { + if a.class == AnimClass::Path { + a.path = Some(default_path(&a.effect, &o)); + } + a.option = o; } } if let Some(b) = build_mode { @@ -355,6 +410,26 @@ fn painter_apply(s: &mut Session, p: &Value) -> Result { #[cfg(test)] mod tests { + use super::resolve_option; + + #[test] + fn direction_words_resolve_to_codes() { + let fly = &["b", "lb", "l", "lt", "t", "rt", "r", "rb"][..]; + assert_eq!(resolve_option(fly, "fromBottom").as_deref(), Some("b")); + assert_eq!(resolve_option(fly, "bottom").as_deref(), Some("b")); + assert_eq!(resolve_option(fly, "fromTopLeft").as_deref(), Some("lt")); + assert_eq!(resolve_option(fly, "fromBottomRight").as_deref(), Some("rb")); + assert_eq!(resolve_option(fly, "lt").as_deref(), Some("lt")); + assert_eq!(resolve_option(fly, "sideways"), None); + assert_eq!(resolve_option(fly, "fromBottomTopLeft"), None); + let wipe = &["r", "l", "u", "d", "ru", "lu", "rd", "ld"][..]; + assert_eq!(resolve_option(wipe, "fromBottom").as_deref(), Some("d")); + assert_eq!(resolve_option(wipe, "upRight").as_deref(), Some("ru")); + let split = &["horzIn", "horzOut", "vertIn", "vertOut"][..]; + assert_eq!(resolve_option(split, "HorzIn").as_deref(), Some("horzIn")); + assert_eq!(resolve_option(&[], "anything").as_deref(), Some("anything")); + } + use super::*; #[test]