From ec5da02a35905ed99de16003d4948e764d86c1d2 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 10 Oct 2026 07:52:38 +0700 Subject: [PATCH] M3: bind effects to variables (#55) Shadow X, Y, blur, spread and colour, and layer/background blur radius take variables from the Effects section like other fields (variable button on hover, a chip to change or detach once bound). Bindings are stored as `effects//` and applied in normalisation; pickers offer effect-scoped variables. Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/src/cmd/styles.rs | 37 +++++++++++++++++++++++++++++ crates/engine/src/cmd/variables.rs | 2 ++ crates/engine/src/variable_tests.rs | 35 +++++++++++++++++++++++++++ crates/ui-egui/src/right.rs | 25 +++++++++++++++---- 5 files changed, 95 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 08d7985a..3f33d940 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -171,7 +171,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). | Auto layout & constraints | 9 | 72% | visual QA of nested edge cases, arrow-key moves across nested auto layout frames | 2–4 | | Text | 8 | 67% | right-to-left layer names in the panels, text style picker depth, axis hover preview | 5–8 | | Components & variants | 9 | 65% | slots, nested override edge cases, library variables | 7–11 | -| Styles & variables | 5 | 78% | binding effects | 1–3 | +| Styles & variables | 5 | 82% | bindings that follow effects when they are reordered, grid and component property bindings | 1–2 | | Prototyping | 6 | 76% | device rotation, presentation scaling options, scroll momentum | 1–3 | | Import/export | 6 | 58% | PDF (in progress), .fig import, REST import of conditional and variable-mode prototype actions | 13–25 | | Dev mode / inspect | 2 | 78% | component code with properties as parameters, visual QA | 2–3 | diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 389e537a..97d414ac 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -467,8 +467,16 @@ pub fn apply_bindings(doc: &mut Document) { let Some(n) = doc.node(id) else { continue }; let binds = n.bound.clone(); let mut sets = serde_json::Map::new(); + let mut effect_sets: Vec<(usize, String, VariableValue)> = Vec::new(); for (field, var) in binds { let Some(val) = doc.variables.resolve(&var, &modes) else { continue }; + // `effects//`: one effect's value. + if let Some((i, prop)) = field.strip_prefix("effects/").and_then(|r| r.split_once('/')) + && let Ok(i) = i.parse::() + { + effect_sets.push((i, prop.to_string(), val)); + continue; + } let jv = match &val { VariableValue::Color(c) => json!(c.to_css_hex()), // Opacity variables hold percentages. @@ -486,6 +494,35 @@ pub fn apply_bindings(doc: &mut Document) { sets.insert(key.to_string(), jv); } let _ = super::props::apply("variables", doc, id, &sets); + if !effect_sets.is_empty() { + apply_effect_bindings(doc, id, &effect_sets); + } + } +} + +/// Write bound values into a node's effects (only what changed, so unchanged layers stay shared). +fn apply_effect_bindings(doc: &mut Document, id: NodeId, sets: &[(usize, String, VariableValue)]) { + let Some(mut effects) = doc.node(id).map(|n| n.effects.clone()) else { return }; + for (i, prop, val) in sets { + let Some(e) = effects.get_mut(*i) else { continue }; + let num = match val { + VariableValue::Number(x) if x.is_finite() => Some(*x), + _ => None, + }; + match (&mut e.kind, prop.as_str(), num, val) { + (EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "offsetX", Some(x), _) => s.offset.0 = x, + (EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "offsetY", Some(x), _) => s.offset.1 = x, + (EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "radius", Some(x), _) => s.radius = x.max(0.0), + (EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "spread", Some(x), _) => s.spread = x, + (EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "color", _, VariableValue::Color(c)) => s.color = *c, + (EffectKind::LayerBlur { radius } | EffectKind::BackgroundBlur { radius }, "radius", Some(x), _) => *radius = x.max(0.0), + _ => {} + } + } + if doc.node(id).is_some_and(|n| n.effects != effects) + && let Ok(n) = doc.get_mut(id) + { + n.effects = effects; } } diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index 1449bea6..1278b883 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -235,6 +235,8 @@ pub fn field_scope(doc: &Document, node: NodeId, field: &str) -> Option<&'static "fontFamily" => "FONT_FAMILY", "fontStyle" => "FONT_STYLE", "stroke" | "strokes/0/color" => "STROKE_COLOR", + f if f.starts_with("effects/") && f.ends_with("/color") => "EFFECT_COLOR", + f if f.starts_with("effects/") => "EFFECT_FLOAT", "fill" | "fills" | "fills/0" | "fills/0/color" => match doc.node(node).map(|n| &n.kind) { Some(NodeKind::Text(_)) => "TEXT_FILL", Some(k) if k.is_frame_like() => "FRAME_FILL", diff --git a/crates/engine/src/variable_tests.rs b/crates/engine/src/variable_tests.rs index ab0799ce..60d97855 100644 --- a/crates/engine/src/variable_tests.rs +++ b/crates/engine/src/variable_tests.rs @@ -82,3 +82,38 @@ fn line_height_and_letter_spacing_follow_number_variables_in_pixels() { s.execute("variable.set", &json!({"variable": "leading", "value": 30})).unwrap(); assert_eq!(style(&s).line_height, figcraft_doc::LineHeight::Pixels(30.0)); } + +#[test] +fn effects_follow_their_variables() { + let mut s = Session::new(); + let r = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"]).unwrap(); + s.execute("effect.add", &json!({"ids": [r], "type": "dropShadow"})).unwrap(); + s.execute("effect.add", &json!({"ids": [r], "type": "layerBlur"})).unwrap(); + s.execute("variable.createCollection", &json!({"name": "Fx"})).unwrap(); + for (name, kind, value) in [("y", "number", json!(6)), ("blur", "number", json!(12)), ("shadow", "color", json!("#FF000080"))] { + s.execute("variable.create", &json!({"collection": "Fx", "name": name, "type": kind, "value": value})).unwrap(); + } + let bind = |s: &mut Session, field: &str, var: &str| { + s.execute("variable.bind", &json!({"ids": [r], "field": field, "variable": var})).unwrap(); + }; + bind(&mut s, "effects/0/offsetY", "y"); + bind(&mut s, "effects/0/color", "shadow"); + bind(&mut s, "effects/1/radius", "blur"); + bind(&mut s, "effects/9/radius", "blur"); + let fx = |s: &Session| s.doc().unwrap().node(r).unwrap().effects.clone(); + match &fx(&s)[0].kind { + figcraft_doc::EffectKind::DropShadow(sh) => { + assert_eq!(sh.offset.1, 6.0); + assert_eq!(sh.color.to_hex(), "FF0000"); + assert!((sh.color.a - 0.5).abs() < 0.01); + } + k => panic!("{k:?}"), + } + assert!(matches!(fx(&s)[1].kind, figcraft_doc::EffectKind::LayerBlur { radius } if radius == 12.0)); + s.execute("variable.set", &json!({"variable": "blur", "value": -3})).unwrap(); + assert!(matches!(fx(&s)[1].kind, figcraft_doc::EffectKind::LayerBlur { radius } if radius == 0.0), "clamped"); + // Pickers offer effect-scoped variables for these fields. + let doc = s.doc().unwrap(); + assert_eq!(crate::cmd::variables::field_scope(doc, r, "effects/0/color"), Some("EFFECT_COLOR")); + assert_eq!(crate::cmd::variables::field_scope(doc, r, "effects/0/spread"), Some("EFFECT_FLOAT")); +} diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 0c7eafba..642e2676 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -1045,6 +1045,11 @@ fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let _ = t; } +/// A number field of effect `i` that can be bound to a variable (`effects//`). +fn fx_field(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, prop: &str, lead: Lead, value: f64, width: f32) -> Option { + crate::variable_ui::number_field(app, ui, ids, &format!("effects/{i}/{prop}"), Id::new(("fx", prop, i)), lead, Some(value), "", width) +} + fn effect_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Effect) { let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0; let set = |app: &mut FigApp, e2: Effect, scrub: Option| { @@ -1072,18 +1077,27 @@ fn effect_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: & let inner = matches!(e.kind, EffectKind::InnerShadow(_)); let mk = |s2: Shadow| Effect { kind: if inner { EffectKind::InnerShadow(s2) } else { EffectKind::DropShadow(s2) }, visible: e.visible }; row(ui, 32.0, |ui| { - let ed = widgets::number_field(ui, Id::new(("fx_x", i)), Lead::Text("X"), Some(s.offset.0), "", half, 1.0); + let ed = fx_field(app, ui, ids, i, "offsetX", Lead::Text("X"), s.offset.0, half); edit(app, ed, &|v| mk(Shadow { offset: (v, s.offset.1), ..s.clone() })); - let ed = widgets::number_field(ui, Id::new(("fx_y", i)), Lead::Text("Y"), Some(s.offset.1), "", half, 1.0); + let ed = fx_field(app, ui, ids, i, "offsetY", Lead::Text("Y"), s.offset.1, half); edit(app, ed, &|v| mk(Shadow { offset: (s.offset.0, v), ..s.clone() })); }); row(ui, 32.0, |ui| { - let ed = widgets::number_field(ui, Id::new(("fx_b", i)), Lead::Text("Blur"), Some(s.radius), "", half, 1.0); + let ed = fx_field(app, ui, ids, i, "radius", Lead::Text("Blur"), s.radius, half); edit(app, ed, &|v| mk(Shadow { radius: v.max(0.0), ..s.clone() })); - let ed = widgets::number_field(ui, Id::new(("fx_s", i)), Lead::Text("Spread"), Some(s.spread), "", half, 1.0); + let ed = fx_field(app, ui, ids, i, "spread", Lead::Text("Spread"), s.spread, half); edit(app, ed, &|v| mk(Shadow { spread: v, ..s.clone() })); }); + // The colour: a bound variable's chip, else swatch, hex and alpha with a variable button. + let color_field = format!("effects/{i}/color"); + let bound = app.session.doc().ok().and_then(|d| crate::variable_ui::bound_name(d, ids, &color_field)).is_some(); row(ui, 32.0, |ui| { + let row_rect = Rect::from_min_size(ui.cursor().min, vec2(half * 2.0 + 8.0, FIELD_H)); + if bound { + let (r, _) = ui.allocate_exact_size(row_rect.size(), Sense::hover()); + crate::variable_ui::bound_chip(app, ui, ids, &color_field, VariableType::Color, r); + return; + } let p = Paint::solid(s.color); if widgets::swatch(ui, &p, 16.0).clicked() { app.ui.picker = Some(crate::colorpicker::PickerTarget::Effect { ids: ids.to_vec(), index: i }); @@ -1095,6 +1109,7 @@ fn effect_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: & } let ed = widgets::number_field(ui, Id::new(("fx_a", i)), Lead::None, Some((f64::from(s.color.a) * 100.0).round()), "%", 56.0, 1.0); edit(app, ed, &|v| mk(Shadow { color: s.color.with_alpha((v / 100.0) as f32), ..s.clone() })); + crate::variable_ui::bind_button(ui, row_rect, ids, &color_field, VariableType::Color); }); if !inner { row(ui, 32.0, |ui| { @@ -1107,7 +1122,7 @@ fn effect_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: & EffectKind::LayerBlur { radius } | EffectKind::BackgroundBlur { radius } => { let bg = matches!(e.kind, EffectKind::BackgroundBlur { .. }); row(ui, 32.0, |ui| { - let ed = widgets::number_field(ui, Id::new(("fx_r", i)), Lead::Text("Blur"), Some(*radius), "", half * 2.0 + 8.0, 1.0); + let ed = fx_field(app, ui, ids, i, "radius", Lead::Text("Blur"), *radius, half * 2.0 + 8.0); edit(app, ed, &|v| Effect { kind: if bg { EffectKind::BackgroundBlur { radius: v.max(0.0) } } else { EffectKind::LayerBlur { radius: v.max(0.0) } }, visible: e.visible,