diff --git a/crates/engine/src/cmd/animation.rs b/crates/engine/src/cmd/animation.rs index e6771a5d..4d969798 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]