diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index b42ddc38d..c03d9ffa9 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use std::sync::Arc; +use designcraft_color::BlendMode; use designcraft_doc::{ Content, Document, Fill, Item, ItemId, ParaFormat, Selection, Shape, SpreadRef, Story, StoryId, Stroke, TextFrame, TextFrameOptions, TextSel, }; @@ -219,7 +220,16 @@ pub fn specs() -> Vec { ), cmd!("object.opacity", "Opacity", [], None, "{opacity: 0..1, blend?: normal|multiply|…, ids?}", has_selection, |s, p| { let o = f64_or(p, "opacity", 1.0).clamp(0.0, 1.0) as f32; - let blend = p.get("blend").and_then(|b| serde_json::from_value(b.clone()).ok()); + // `BlendMode::parse` takes the documented lowercase forms (`multiply`), the panel + // labels (`Color Burn`) and the serialized names (`Multiply`). An unknown value is + // an error rather than a silent no-op that still reports `changed` (#260). + let blend = match p.get("blend") { + None | Some(Value::Null) => None, + Some(v) => { + let raw = v.as_str().ok_or_else(|| bad("object.opacity", "blend must be a string"))?; + Some(BlendMode::parse(raw).ok_or_else(|| bad("object.opacity", format!("unknown blend mode `{raw}`")))?) + } + }; set_flag( s, p, @@ -2513,3 +2523,67 @@ mod column_rule_tests { assert_eq!(opts(&s), before); } } + +#[cfg(test)] +mod blend_mode_tests { + use designcraft_color::BlendMode; + + use super::*; + + /// `object.opacity` documents `blend?: normal|multiply|…`, but deserializing the value + /// against the enum only accepted the PascalCase variant names, so the documented + /// lowercase forms were silently dropped: the command reported `changed: 1` while the + /// stored blend stayed as it was (#260). + #[test] + fn lowercase_documented_blend_modes_apply() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "unassigned"})).unwrap(); + let id = ItemId(r["id"].as_u64().unwrap()); + let blend = |s: &Session| s.doc().unwrap().doc.item(id).map(|i| i.blend); + + for (given, want) in [ + ("multiply", BlendMode::Multiply), + ("screen", BlendMode::Screen), + ("MULTIPLY", BlendMode::Multiply), + ("Color Burn", BlendMode::ColorBurn), + ("color-burn", BlendMode::ColorBurn), + ("soft_light", BlendMode::SoftLight), + ("normal", BlendMode::Normal), + ] { + let out = s.execute("object.opacity", &json!({"opacity": 1, "blend": given})).unwrap(); + assert_eq!(out["changed"], 1, "{given}"); + assert_eq!(blend(&s), Some(want), "blend mode {given:?} must be applied"); + } + } + + /// The PascalCase names keep working, so the Properties panel's own submissions are + /// unaffected. + #[test] + fn pascal_case_blend_modes_still_apply() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "unassigned"})).unwrap(); + let id = ItemId(r["id"].as_u64().unwrap()); + s.execute("object.opacity", &json!({"opacity": 0.5, "blend": "Multiply"})).unwrap(); + let it = s.doc().unwrap().doc.item(id).cloned().unwrap(); + assert_eq!(it.blend, BlendMode::Multiply); + assert!((it.opacity - 0.5).abs() < 1e-6, "opacity must still be set"); + } + + /// A blend mode the command doesn't know is an error, not a silent success that leaves + /// the previous appearance in place. + #[test] + fn an_unknown_blend_mode_is_an_error() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "unassigned"})).unwrap(); + let id = ItemId(r["id"].as_u64().unwrap()); + s.execute("object.opacity", &json!({"opacity": 1, "blend": "Multiply"})).unwrap(); + let e = s.execute("object.opacity", &json!({"opacity": 1, "blend": "sparkle"})).unwrap_err().to_string(); + assert!(e.contains("sparkle") && e.contains("blend"), "{e}"); + let it = s.doc().unwrap().doc.item(id).cloned().unwrap(); + assert_eq!(it.blend, BlendMode::Multiply, "a rejected value changes nothing"); + assert_eq!(it.opacity, 1.0); + } +}